Going Stellar

Home/Methods

Apps · Atlassian Forge

How we build and ship Atlassian Forge apps

We built 25 Jira and Confluence apps on a few shared chassis and submitted them together. Four are public; the other 21 were rejected and are stuck behind a listing state only Atlassian can clear, because we skipped the step that would have shown us the wall: taking one app all the way through review first.

Updated

What this covers

Forge is Atlassian's platform for Jira and Confluence Cloud apps. Atlassian hosts the code and the app runs inside the product. In August 2026 we built 25 Forge apps, 9 for Jira and 16 for Confluence, and submitted all of them to the Atlassian Marketplace. This is the method we use now, including the parts we only learned by getting them wrong.

On 25 September 2026 four apps were public on the Marketplace: Epic Roll-up, SprintEstimate, Templatize and Recurring Issues, each checked on its public page. The other 21 were rejected on 8 September and cannot be resubmitted until Atlassian clears a listing state on its side; on 29 September its support team described the case as an edge case it was working on. On 21 September the public data showed no installs for any of our apps. Listed and used are different things, and so far we can show only the first.

One chassis, many apps

We do not design 25 apps. We design a chassis and fill it. A Jira app is a manifest, a resolver (the backend function the interface calls) and a UI Kit front end. The reference is SprintEstimate, a planning-poker board, tested end to end before anything was copied from it. Confluence macros that call a language model use a second chassis built on Custom UI; eleven of those macros share it, and we confirm that their licence check is byte-identical across all eleven with a hash, not by reading.

Tests run against the deployed app. We drive the live app through its real interface and then read the side effect at the source, through the Jira REST API: the field value, the number of issues created, the author of a comment. That caught a Save button that sent an empty value instead of the average it displayed, and it showed us how a test can lie. A Confluence macro's live preview uses the same CSS classes as the saved macro, so a test that looked for those classes passed while no user could save any of the 10 macros we had shipped. A save is now proven by the configuration dialog closing and the macro rendering without its editor fields.

Licence gating, proven by effect

A paid Forge app reads its licence from the request context and withholds the paid feature when there is none. Ours read a field that does not exist on the backend. The front-end object calls it active; the object a resolver receives calls it isActive, and the resolver framework replaces whatever the front end sent. In all 20 monetised apps, across 34 places in the code, the paid check was therefore always false: a paying customer would have received the free tier. The fix reads either name.

Absent is not the same as inactive. Forge sends no licence object for free editions and non-production installs, so treating a missing licence as unpaid locks out people who are entitled to be there. We use three states: paid, lapsed and unknown, and unknown never blocks.

Reading the code was not accepted as proof. forge install accepts a licence value such as active or inactive on a development install, so the paid path can be exercised before approval and without any payment. On our issue-cloning app an inactive licence created exactly 1 issue and an active one created exactly 3. We counted them through the API, not from the success message. The same test found one app whose limit existed only in the browser: the backend sent everything and the front end hid the excess, so anyone with developer tools had the paid tier. The limit is now applied in the resolver.

Resolvers run as the user

Atlassian scans every submitted version with a static analyser and opens a security ticket with a deadline. On 31 August it rejected two of our apps for a missing authorisation check, at severity P2. The cause was a class of bug, not two bugs. Every product call in the resolver used api.asApp(), so anyone who could open the panel acted with the app's permissions: one app could show an issue's change history to a user not allowed to see the issue, another could create issues where the user had no permission to create them.

The scanner found two; a grep across the fleet found five. The rule now: resolvers call api.asUser(), so the product enforces the caller's own permissions, and asApp() is allowed only in scheduled triggers, where there is no caller. The helper defaults to the user, so a new call site is safe by omission. Every write is also gated with Forge's authorise API and fails closed.

We verified the fix by effect: in each app the issue or comment it created had the invoking user as its author, not the app. On 1 September, after resubmission, Atlassian's scanner rescanned both apps and marked the tickets patched. It sent no email when it did.

The review happens in a ticket

Every submission opens a ticket in Atlassian's service desk, announced by a single email. The reviewer's questions live in that ticket: test credentials for any third-party system the app integrates with, the reason for each API scope, and every remote host the app sends data to. If nobody answers, the review expires with the reason Timeout, a word we could not find anywhere in Atlassian's documentation.

We learned this late. On 27 August, 12 of 20 listings had been sitting rejected for Timeout for over two weeks. The emails came from a domain we were not searching and the mail client filed them away from the inbox. Nothing was wrong with the apps; we had not answered.

Now we capture the ticket at submission, confirm we can open it, and answer the three questions the same day. A watchdog runs twice a day and reads the platform's own state as well as the mail, because a status can change with no email at all.

The Sharing trap

The reviewer has to install the app. A Forge app that has a licence in its manifest, or has been submitted for listing, cannot be shared through an installation link, and a listing cannot be installed before it is approved. We built the unlicensed copies Atlassian's documentation prescribes, 25 of them; the reviewer then told us testing happens only on the submitted app.

The real cause took weeks to find. The Marketplace interface refuses to list an app whose distribution status in the developer console is not Sharing. We had created 21 listings through the console's own GraphQL calls, where that check does not exist. The 4 apps we had set to Sharing before listing went through review and are the four that are public now. The 21 ended up in a state the console cannot represent: the Sharing control stayed disabled after rejection, and removing the licence did not release it. Atlassian's support told us to publish through the interface, one app at a time, so that every validation runs.

When an API accepts something the form refuses, that is not a shortcut, it is a debt. Set distribution to Sharing first, then create the listing in the interface.

Where we were wrong: pilot one before the fleet

The expensive error was not a bug. We submitted 25 apps at once without taking one of them through review to approval. A pilot would have hit the Sharing wall on the first app, in the first week, with 24 still unbuilt. Instead we multiplied a deadlock 25 times.

A smaller error of the same kind: we once reported a breakthrough after reading the page's own help text (Select the Sharing option, fill in the app details, and select Save changes) as if a form had opened. We now prove a blocked control by removing its disabled attribute, clicking for real and counting dialogs, inputs, network requests and the length of the page text. On the locked app every count stayed at zero and the text was identical to the character; on an unlicensed copy the same click opened the form. The contrast is the proof.

When we did pilot, on a single listing that Atlassian removed so we could recreate it, it exposed a further layer, an app key that stayed reserved after the listing was archived. We found it on one app instead of on 21.

Limits

The method lowers the chance of a rejection or a broken paid path. It does not produce customers: an approved listing that nobody is searching for is a shelf, not an audience. Our licence tests cover our code, not Atlassian's billing, and a real Marketplace purchase has not yet been observed end to end. The review steps described here are the ones we met in 2026.

Checklist

1. Reference app. Build one app on the chassis and test it live, reading side effects at the source, before copying it.

2. Identity. No asApp() outside scheduled triggers; every write gated and failing closed.

3. Licence. Read both field names, treat a missing licence as unknown, and effect-test active and inactive installs.

4. Sharing first. Set distribution to Sharing, then create the listing through the interface.

5. The ticket. Capture it at submission, answer the reviewer's three questions the same day, and watch platform state, not only mail.

6. Pilot. One app goes all the way to approval before the rest follow.

Questions and answers

Why was my Atlassian Marketplace app rejected with the reason Timeout?

An Atlassian Marketplace review expires with the reason Timeout when nobody answers the reviewer in the service desk ticket opened at submission. The reviewer's questions live in that ticket: test credentials for any third-party system, the reason for each API scope, and every remote host the app sends data to. Capture the ticket at submission, confirm you can open it, answer the same day, and watch the platform state as well as email.

Why can't a reviewer install my Forge app before it is approved?

A Forge app that has a licence in its manifest, or has been submitted for listing, cannot be shared through an installation link, and a listing cannot be installed before approval. The Marketplace interface also refuses to list an app whose distribution status is not Sharing. Creating listings through the console's GraphQL calls skips that check and can leave apps in a state only Atlassian can clear. Set distribution to Sharing first, then list through the interface.

How should a Forge app check whether a customer has a paid licence?

A Forge app should read the licence from the request context and accept both field names, because the front-end object calls it active while the resolver receives isActive. A missing licence object means unknown, not unpaid, since Forge sends none for free editions and non-production installs. Prove the gate by effect: install with an active and an inactive licence on a development install and count the results through the API.

Should Forge resolvers use asApp or asUser?

Forge resolvers should use api.asUser(), so Jira or Confluence enforces the permissions of the person making the request. With api.asApp(), anyone who can open the panel acts with the app's permissions, which can expose issue history or allow issue creation the user is not entitled to. Reserve asApp() for scheduled triggers, make the helper default to the user, and gate every write with Forge's authorise API so it fails closed.

How do you test an Atlassian Forge app reliably?

Test the deployed Forge app, not the code: drive the live app through its real interface, then read the side effect at the source through the Jira REST API, such as a field value, the number of issues created or a comment's author. Tests that look only at the interface can lie. A save is proven by the configuration dialog closing and the macro rendering without its editor fields, not by finding CSS classes.

How to cite this

Going Stellar (2026). How we build and ship Atlassian Forge apps. GOING STELLAR S.R.L..
https://goingstellar.ro/methods/how-we-build-and-ship-atlassian-forge-apps/

Read as Markdown

All methods

Contact

Tell us what is not working

Write in English or Romanian. A person reads it — usually the same person who would do the work — and answers within one business day.