Notes from my first Forge app submission

I submitted my first Forge app to the Marketplace this week. The app itself took
about two days. Getting it accepted took considerably longer, and almost none of
that was code.

I’m writing this down because the platform docs cover each piece in isolation
and are accurate about all of them, but nothing I found laid out the order
things happen in, or which steps silently depend on each other. I hit six dead
ends that were each obvious in hindsight and completely opaque at the time.

Disclosure so nobody has to wonder: I build Mermaid Studio for Confluence, and
this was its first submission. I’m not linking it. This post is about the
process, not the app.

The partner account rejected me with a 412

First attempt at creating a Marketplace Partner profile:

The underlying service call failed.
The underlying service marketplace_console_bff status code is: 412

That is a Precondition Failed with no indication of which precondition. I
verified my domain, set up a business mailbox, enabled 2FA, and tried again.
Same error.

What actually fixed it was shortening the organisation name. I never got
confirmation of why, and the error never changed to tell me. If you hit this,
try a simpler org name before you spend an evening on anything else.

Verifying a domain is not the same as changing your account email

Paid-via-Atlassian apps cannot use a personal email domain. I knew this, so I
bought a domain, set up a mailbox, and claimed the domain in Atlassian admin.

Then forge whoami still said:

Logged in as Rolf Eriksen (redacted for privacy)

Claiming a domain in the org admin proves you control it. It does not change
which address identifies your account. That lives in your personal account
settings at id.atlassian.com, and it is a completely separate screen. I spent an
hour assuming the first thing had done the second.

Worth knowing: changing the address on an existing account preserves the account
ID, so your apps and Developer Spaces come with you. Creating a second account
would have meant re-registering everything.

I ended up with two Developer Spaces and no way to bridge them

This one cost the most time.

My app was registered in the Developer Space I created when I first ran
forge register. When my partner account was approved, it came attached to a
different, empty Developer Space.

The Marketplace “Publish a new app” form reads from the partner-linked space, so
it showed:

No Forge apps available

with a hint about enabling sharing and publishing the Developer Space. Sharing
was already on. The space it wanted was the empty one.

forge developer-spaces list only ever showed one of the two. Attempting to
register into the other returned:

The Developer Space does not exist, or you don't have the correct permissions
to access it.

The console has a Transfer function for exactly this, which is the right
answer. But it told me:

No destination Developer Space available. You don't have another Developer
Space where you have the Admin role.

I was the org admin. I was not an Admin on that space, which is a different
grant. And the reason I could not grant it to myself was that the space hadn’t been set up yet. Setting up the space with just a couple clicks unlocked the Admin role, which unlocked
Transfer, which moved the app with its ID, versions and installs intact.

So the actual chain was: billing > space Admin role > Transfer > app appears in
the Marketplace form. Nothing surfaced that chain. Each error described only its
own link.

If you are starting from scratch, register your app after your partner account
exists and use the space it gives you. You will avoid all of this.

You cannot install your own licensed app from the CLI

Once you set licensing.enabled: true in the manifest, which you must do to
charge for the app:

forge install -e production --product confluence --site your-site.atlassian.net
Error: Installation error: Installing a licensed app is not permitted

This is correct behaviour, not a bug. Licensed apps install through the
Marketplace so entitlement can be attached. It means your production build gets
tested via a private listing install, not from your terminal. Plan for that,
because “test production before submitting” is good advice that quietly requires
the listing to exist first.

The first two rejections were automated and vague

Automatic Rejection - Not enough details on listing

then, after I expanded the listing:

Automatic Rejection - Invalid Links

Neither says which detail or which link. What eventually got me through:

  • Filling every optional-looking field, not just the required ones
  • Captions on every image, not just the images themselves
  • In a field I was supposed to input a link to my website, I had instead written an explanation, because the field looked different than the other link input fields, so it confused me.

Paid apps need a support portal, and email does not count

The rejection that finally explained itself:

We are rejecting this app submission due to the absence of a support portal
link. For apps with Payment Model set as Paid via Atlassian, it is mandatory
to have a public support portal.

I had a support email address on my own domain and a support page. Not enough.
Customers must be able to raise and track a ticket.

Jira Service Management free tier covers this. Add JSM to your site, create a
service project, and set customer permissions so anyone can raise a request
without an invitation. Then check the portal URL in a private window. If it
demands a login, it is not public and it will fail the same check.

The default IT service management template gives you request types like “Report
broken hardware” and “Onboard new employees”. Delete all of them. For an app
vendor, four types cover it: report a problem, ask a question, suggest a
feature, billing or licensing.

The security questionnaires assume you have a security department

Two of them, and they ask about log retention policies, quarterly infrastructure
vulnerability scans, business continuity testing, SBOM maintenance, and periodic
access reviews.

My app is static resources running in the browser with no backend and no
permission scopes. There is no infrastructure to scan and no logs to retain.

The temptation is to answer Yes so the form looks respectable. Do not. These are
on record and several are verifiable. I answered No where the control genuinely
does not exist, and used the free-text field to explain that the controls are
absent because the attack surface is, rather than because I skipped them.

The second questionnaire has no free-text field. For that one I answered
honestly and posted the same explanation as a comment on the ticket.

Two things worth having ready before you start either form: a published security
policy and a written incident response plan. Both are short if you have no
infrastructure, and both turn a No into a Yes legitimately.

Four API details that cost me an hour each

Unrelated to submission, but they were the non-obvious ones during the build:

Macro config submits nested. view.submit({ source, theme }) fails with
Invalid “config” provided. Expected object. It wants
view.submit({ config: { source, theme } }).

view.resize() does not exist. I had guessed it did and wrapped it in a
feature check, so it silently never ran and my macro never sized correctly. The
actual mechanism is view.emitReadyEvent().

CQL cannot find Forge macros. macro = "your-macro-key" only matches
Connect macros. I built an admin page listing every page using my macro before
discovering this. Tracked as CONFCLOUD-81490.

CSS transforms do not resize a macro iframe. I implemented zoom with
transform: scale(). The diagram grew visually and the iframe did not, so
content overflowed a box that never changed height. Setting an explicit pixel
width reflows, and the auto-resizer then follows.

What I would do differently

Create the partner account first, then register the app into the space it gives
you. Set up the support portal before writing a line of listing copy. Publish
the privacy policy, terms, SLA and security policy as real pages early, since
four separate forms ask for those URLs.

The build was the easy part. I expected that to be the other way round.

Happy to answer questions if anyone is going through the same thing.

Thanks for sharing @RolfEriksen - this is GOLD :coin: :coin:

Thanks Ulrich, glad it’s useful. I almost didn’t write it, assumed everyone else had figured this out faster than I did.

Since you’ve been at this a lot longer: is there anything about the first year you’d want to have known at the start? The submission gauntlet I could at least document as I went. The part after, actually getting found, I have no map for.

LOL - I suppose my ‘first year’ experience is now > 10 years ago, and thinking back, any advice would be dangerously outdated. Feel free to DM me on any blockers you’re experiencing on your path as a MP vendor.

These are on record and several are verifiable.

Worth knowing the failure in the other direction, for the ones you answer Yes to. A clean npm audit can be contradicted by the reviewer running osv-scanner over the same tree, and it is not a scanner bug.

We hit this on xlsx. npm’s copy is frozen at 0.18.5 because SheetJS publishes off-registry now, so both HIGH advisories carry:

"ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]

No fixed event, because GitHub has no in-npm version to name as the fix. Under OSV semantics that reads as affected at every version, so on 0.20.3 npm audit is clean while osv-scanner still reports both.

The tell that it is the data model and not real exposure: the 2021 SheetJS advisories (GHSA-3x9f-74h4-2fqr and its two siblings) do carry {"fixed": "0.17.0"}, and match neither version. Same package, same database. The newer two also publish last_known_affected_version_range of < 0.19.3 and < 0.20.2, which puts 0.20.3 outside both.

Cheaper to raise it in the pack yourself than to be second to it. Any dep pulled from outside the registry tends to land the same way.

More of these in our forge-security-review notes.

Thanks, that’s exactly the kind of thing I’d have been second to.

Checked ours before replying: 207 resolved production packages across four lock files, clean under both npm audit and a direct OSV query. No off-registry deps, so nothing landed in the shape you describe. I wouldn’t have thought to verify the two independently.

For anyone wanting to do the same without installing a scanner: OSV’s querybatch endpoint takes 100 package/version pairs per POST, so a full tree is one or two calls. Worth it just to be able to say how you verified rather than “yes”.

Adding the method and date to our security pack rather than only the answer.

Thanks Ulrich, I might be too new here for DMs, but honestly the question isn’t private, so I’ll just ask it in the open in case it’s useful to anyone else.

The thing I keep getting wrong is reading install counts. I’ve been using them to judge whether a category is worth entering and I’ve misjudged it more than once, most recently assuming an app had plateaued at ~40 installs, when it turned out it had only gone paid nine months earlier.

How do you read them? Mainly whether a few hundred installs is a business or a hobby, and how much of that depends on tier mix rather than the raw count.