A Forge app REST API is called from outside Jira with an OAuth 2.0 (3LO) access token. This tutorial does it end to end with curl, against LeanZero Management, the free planning app we make for Jira Cloud. The first call I made on 2 October answered 403 REST APIs are not allowed for appId=…. Eight minutes and one admin click later, the same token created a plan over a 54-issue project. By the end of the evening I had read its health check, deleted it again, and collected four different 403s.
Three things decide whether you get a 200 or a 403. An admin switch that is per product install. A parameter called sns on the authorize URL. And the tier you put in the path. The access token lasts 3,600 seconds and the refresh token rotates every time you use it. Atlassian still calls the whole feature a Preview. And one line in our own setup guide turned out to be wrong, which I'll get to.
Everything below ran on wolfaenpak, our own test Jira Cloud site, against the development build of the app. A production install answers on the same kind of URL with a different environment id. I did not make a production call for this article, and I say where that matters.
[!NOTE]
Prerequisites
- A Jira Cloud site with LeanZero Management installed from the Marketplace listing. It is free. The REST API arrived in release 4.12.0, which runs as Forge version 5.0.0; the listing showed 5.17.0 when I checked on 2 October. A site on any version before 5.0.0 needs an admin to approve the major update in Apps, Manage apps first.
- A site or organization admin for one click in Atlassian Administration.
- An Atlassian account that is a member of that site. That account creates the integration and, while the integration is private, is the only one that can consent to it.
- curl, jq and Python 3 on your machine. I used curl 8.7.1, jq 1.7.1 and Python 3.9.6 on macOS.
The gateway checks the token, the app checks the person
Before the steps, the one picture you need. Your script never talks to the app directly. It calls a URL on your own site.
https://<site>.atlassian.net/gateway/api/svc/jira/apps/<app-id>_<env-id>/<tier>/<resource>
Atlassian's own description is short. "Atlassian validates the token, checks the scopes, and routes the request to your app function defined by apiRoute". So the gateway decides whether the token may reach the app at all. Then the app decides what that person may do, with their plan role and their Jira project permissions. A token never gives anyone more than they already have in the UI.
LeanZero Management registers three scopes, and each one opens one path tier:
| tier in the path | scope you grant | consent screen wording | what it allows |
|---|---|---|---|
/viewer/… |
read:plan:custom |
Read LeanZero Management plans | read plans, issues, schedules, exports, snapshots, reports |
/editor/… |
write:plan:custom |
Change LeanZero Management plans | create and change plans, index, dates, dependencies, apply dates to Jira |
/admin/… |
manage:plan:custom |
Administer LeanZero Management plans | delete plans, snapshots and reports, manage members, read the audit log |
One detail matters more to me than the rest. The app takes the caller's identity from the invocation context Atlassian hands it (whoami prints principalFrom: context), and refuses a header that disagrees with it. In September we probed it with a forged x-slauth-user-context-account-id header. Every call still acted as the person who consented. The gateway had overwritten the caller's copy before it reached us. Good.
Follow the procedure in seven steps
[[steps]]
- Switch on App REST APIs for the Jira install — a site or org admin, in Atlassian Administration, Connected apps. Without it every call is a 403.
- Create an OAuth 2.0 integration — in the developer console, resource-level, named for your script.
-
Grant the app's scopes — Add Marketplace or custom app, pick your site and LeanZero Management, tick the scopes the script needs. Add Jira's
read:forge-app:jiraas well. - Set the callback and copy the credentials — a localhost callback URL, then the client ID and secret into environment variables.
- Consent once, with sns on the URL — open the authorize URL signed in as the integration's owner, press Accept, read the code off your local listener.
- Exchange the code for tokens — one POST to auth.atlassian.com, and keep the refresh token.
- Call the API — whoami first, then create, read and delete a plan, refreshing the token when it expires.
Enable app REST APIs in Connected apps
This is the one step there is no API for. It's a click. Atlassian is clear that it starts off: "Forge app REST APIs are disabled by default for each site and must be explicitly enabled by a site or organization admin".
The path is long: Atlassian Administration, your organization, Apps, Sites, your site, Connected apps, LeanZero Management, View app details, and the App REST APIs section on the Details tab. Press Enable app REST APIs and confirm the dialog.
Here's the trap. It cost me the first 403. LeanZero Management installs on Jira and on Confluence, so Connected apps shows it as one expandable row with two installs, each with its own details page and switch. On our site the Confluence install had the switch on and the Jira install had it off. The API this tutorial calls lives on the Jira side (svc/jira in the URL), so the Jira one is the switch that counts. Check the first row you open and stop there, and you can flip the wrong one.
Before I switched it on, the very first call answered this. Plain text, not JSON.
HTTP/2 403
content-type: text/plain
REST APIs are not allowed for appId=087a8e18-d45a-4cb7-9d87-3e84101ac4f3, envId=d6096af9-3082-4ee1-a05e-f8b61d766b77
How you know it worked: the section reads "App REST APIs Enabled" and shows a "Base URL for this" field. That base URL is the $LZM_BASE you will use in step 7.
Note the second paragraph on that screen. "Any user in your organization with a verified Atlassian account can create these integrations." Switching it on doesn't hand anyone access to plans. It lets people build integrations that act as themselves.
If you're a Jira admin on a production site, there's a shorter way to the base URL. Settings, API Access in LeanZero Management prints it, and the consent parameter, for the site you're on.
That screenshot is production on our demo site. Its environment id is 5c1c7532-…, not the development d6096af9-… in my commands. Use whatever your own site prints. Send a production URL to a site where production isn't installed and you get 404 No extensions found in response.
Create the OAuth 2.0 (3LO) integration: scopes for the Forge app
Go to developer.atlassian.com, Developer console, Create, OAuth 2.0 integration. Name it for the script (mine is lzm-plans-script). Pick Resource-level, so it can only act on the one site you choose at consent. Agree to the terms, Create.
The create page now carries a banner that matters later. "New OAuth 2.0 integrations must use rotating refresh tokens." Remember it for step 6.
Open Permissions and press Add Marketplace or custom app. Pick your site, then LeanZero Management, then the scopes. The dialog only offers what the app registered, and says so. "Only scopes registered by this app are available. If no scopes appear, REST APIs are not set up for this app".
When you press Add, the console shows the base URL once, in an "App added successfully" dialog. Its Select the Product box said confluence for me. Wrong product. Copy it before you close the dialog, because the console won't show it again, and change svc/confluence to svc/jira in it. The Jira and Confluence installs share the app id and environment id; only that path segment differs.
Grant the fewest scopes the script needs. A weekly report script needs read:plan:custom and nothing else. I ticked all three because this tutorial deletes the plan it creates, and deleting is an admin action.
Then add Jira's product scope: Permissions, Jira API, Add, Configure, the Granular scopes tab (not Classic), Edit Scopes, search forge-app, tick read:forge-app:jira, Save. Atlassian's guide says to. "In addition to these app-defined scopes, add the Forge app product scopes required for the relevant Atlassian apps (currently Jira and Confluence)".
Where I was wrong about read:forge-app:jira
Our own REST API docs said, and the setup card in the app still says, that without read:forge-app:jira "every call answers 401". I wrote that in September. It didn't hold when I tested it. On 2 October I tested it twice. A token consented without the scope answered whoami with a 200. Then I removed the scope from the integration entirely, consented again, and still got a 200. I did not see a 401 "scope does not match" once, in any of the consents I ran that evening.
That isn't the last word, though. In June someone on the developer community hit Unauthorized; scope does not match on a Confluence app, and an Atlassian staff member told them a missing read:forge-app:confluence "will also result in 401 Unauthorized". Adding it fixed their integration (the thread). So it's enforced somewhere, or was, and it wasn't for my Jira integration on 2 October.
So here's the honest version. Atlassian tells you to add it, a staff answer says leaving it out can cost you a 401, and my run didn't need it. Add it anyway. I'd rather you didn't find out the difference from a failed nightly job. I've corrected our REST API docs to say this. The card in the app and our product page still say it the old, stronger way as I publish this.
How you know this step worked: the Permissions page reads "Scopes Used 3" after the app scopes. The Jira API row shows one scope after read:forge-app:jira.
Set the callback and copy the client ID and secret
Open Authorization, add OAuth 2.0 (3LO), and put a callback URL in the "Callback URLs" box. A local script can use http://localhost:9876/callback. Save changes.
The page then shows an "Authorization URL generator". The URL it gives for the custom app already carries the three scopes, read:forge-app:jira and the sns value. What it does not carry is offline_access, so it will never get you a refresh token. Our docs also claimed the console URL "may lack read:forge-app:jira and sns". For this integration it had both, but only because I'd ticked read:forge-app:jira in the step before. The console adds the Jira scope you selected, nothing more, so their hedge stands. Fine.
Open Settings and copy the Client ID and the Secret. Keep them out of git:
export CLIENT_ID='<client id from Settings>'
export CLIENT_SECRET='<secret from Settings>'
export REDIRECT='http://localhost:9876/callback'
export SNS='087a8e18-d45a-4cb7-9d87-3e84101ac4f3.<env-id>'
export LZM_BASE='https://<site>.atlassian.net/gateway/api/svc/jira/apps/087a8e18-d45a-4cb7-9d87-3e84101ac4f3_<env-id>'
SNS is the app id and the environment id joined by a dot. LZM_BASE joins the same two with an underscore. Both come off the API Access screen above, or out of the base URL the console showed you once: the two ids after /apps/.
Consent once, with sns on the authorize URL
Build the authorize URL. The scopes go in space-separated and URL-encoded. offline_access is what earns you a refresh token.
export SCOPES="read:plan:custom write:plan:custom manage:plan:custom read:forge-app:jira offline_access"
export STATE=$(openssl rand -hex 12)
echo "https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=$CLIENT_ID&scope=$(jq -rn --arg s "$SCOPES" '$s|@uri')&redirect_uri=$(jq -rn --arg s "$REDIRECT" '$s|@uri')&state=$STATE&response_type=code&prompt=consent&sns=$SNS"
It printed this for me, with the client id and state replaced.
https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=<CLIENT_ID>&scope=read%3Aplan%3Acustom%20write%3Aplan%3Acustom%20manage%3Aplan%3Acustom%20read%3Aforge-app%3Ajira%20offline_access&redirect_uri=http%3A%2F%2Flocalhost%3A9876%2Fcallback&state=<STATE>&response_type=code&prompt=consent&sns=087a8e18-d45a-4cb7-9d87-3e84101ac4f3.d6096af9-3082-4ee1-a05e-f8b61d766b77
You need something on port 9876 to catch the redirect, and it doesn't have to be clever, because all you want from it is the one line it logs. Python's built-in server logs the full request line, code included. It answers 404. That's fine.
python3 -m http.server 9876
Open the URL in a browser where you're signed in as the integration's owner. While the integration isn't shared, nobody else can consent. Atlassian says a new 3LO app is "private by default". When we tried another account in September, the page said "only the owner of this application may grant access". A script that should act for several people needs Distribution, Sharing.
How you know it worked: the consent screen lists LeanZero Management under its own heading, with the permissions you granted.
Press Accept. The browser lands on the callback and the Python server logs this.
::1 - - [02/Oct/2026 20:52:04] code 404, message File not found
::1 - - [02/Oct/2026 20:52:04] "GET /callback?state=<STATE>&code=<CODE> HTTP/1.1" 404 -
Check that state matches the one you generated. Copy the code value. Stop the server.
Now the trap that would have cost me an hour if I hadn't been looking for it. I ran the consent once without sns. The page looked normal and Accept worked, but the consent screen showed only the Jira line, no LeanZero section. The token that came back had one scope, read:forge-app:jira. The app's scopes had been dropped without a word. The first call then said:
Missing required scopes for path=/viewer/whoami, method=GET. Missing: [read:plan:custom]
That's a 403, again in plain text. Nothing earlier warns you. The fix is the URL, not the integration. None of the five Atlassian pages I read for this mentions sns at all. An Atlassian staff member called it a "semi-documented" parameter in the thread above. It's in the URL the console generates, and it's what the app labels "Consent parameter".
Exchange the code and keep a 3LO refresh token (offline_access)
One POST turns the code into tokens. token.json will hold a refresh token that acts as you, so keep it out of git and make it readable only by you. Set the umask first, then paste the code into CODE.
umask 077
export CODE='<the code= value from the log>'
curl -s -X POST https://auth.atlassian.com/oauth/token \
-H "Content-Type: application/json" \
-d "{\"grant_type\":\"authorization_code\",\"client_id\":\"$CLIENT_ID\",\"client_secret\":\"$CLIENT_SECRET\",\"code\":\"$CODE\",\"redirect_uri\":\"$REDIRECT\"}" \
> token.json
jq '{token_type, expires_in, scope, refresh_token: (.refresh_token | type)}' token.json
You should see this.
{
"token_type": "Bearer",
"expires_in": 3600,
"scope": "087a8e18-d45a-4cb7-9d87-3e84101ac4f3.d6096af9-3082-4ee1-a05e-f8b61d766b77:manage:plan:custom 087a8e18-d45a-4cb7-9d87-3e84101ac4f3.d6096af9-3082-4ee1-a05e-f8b61d766b77:read:plan:custom 087a8e18-d45a-4cb7-9d87-3e84101ac4f3.d6096af9-3082-4ee1-a05e-f8b61d766b77:write:plan:custom offline_access read:forge-app:jira",
"refresh_token": "string"
}
Read the scope line. Carefully. The app's scopes come back prefixed with <app-id>.<env-id>:, Atlassian's own scopes don't. If you don't see the prefixed ones, your consent had no sns, and there's no point calling the API yet. If refresh_token says null, you forgot offline_access.
A code works once. I checked. I sent the same one a second time and got {"error":"invalid_grant","error_description":"authorization_code is invalid"}.
The access token lasts 3,600 seconds. After that, refresh it.
umask 077
curl -s -X POST https://auth.atlassian.com/oauth/token -H "Content-Type: application/json" -d "{\"grant_type\":\"refresh_token\",\"client_id\":\"$CLIENT_ID\",\"client_secret\":\"$CLIENT_SECRET\",\"refresh_token\":\"$(jq -r .refresh_token token.json)\"}" > token.new.json
jq -c "{expires_in, refresh_token: (.refresh_token | type)}" token.new.json
[ "$(jq -r .refresh_token token.json)" = "$(jq -r .refresh_token token.new.json)" ] && echo same || echo rotated
mv token.new.json token.json
{"expires_in":3600,"refresh_token":"string"}
rotated
That rotated is the banner from step 2 at work. Every refresh hands back a new refresh token, and your script has to store it, every time. Atlassian's 3LO page puts it bluntly. "Your code should replace the existing refresh token with the new refresh token." It also documents two numbers. A 90-day inactivity expiry. And a 10-minute "reuse interval or leeway", inside which breach detection doesn't fire on a refresh token used twice. I did reuse an old one 10 seconds after rotating it and it still worked, which fits that leeway. I didn't test what happens after 10 minutes, and I wouldn't build a script that relies on finding out. The mv line is the whole fix.
Call the Forge app REST API with curl: whoami, plans, health
Start with whoami. Always. It tells you who the call acts as and which door it came through, which is the first thing you'll want to know when a number looks wrong later.
export ACCESS_TOKEN=$(jq -r .access_token token.json)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/viewer/whoami" | jq "{role: .token.role, door, scope, app, version, ai}"
{
"role": "viewer",
"door": "api-route",
"scope": "read:plan:custom",
"app": "LeanZero Management",
"version": "8.328.0",
"ai": {
"window": {
"used": 0,
"limit": 20,
"minutes": 10,
"resetsAt": "2026-10-02T18:00:00.000Z"
},
"today": {
"actions": 0,
"limit": 200
}
}
}
version is the build that answered. 8.328.0 is our development build, not something you'll see on a customer site. The ai block is the app's own allowance for AI actions over the API: 20 per 10 minutes and 200 a day, per person. Atlassian doesn't state a rate limit for app REST APIs on any of the pages I read, so I won't quote one.
The tier in the path sets the role. Same token, editor path.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/editor/whoami" | jq -c "{role: .token.role, scope}"
{"role":"editor","scope":"write:plan:custom"}
List the plans you can see. The answer is filtered to the person who consented, so a colleague running the same command with their own token can get a different count.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/viewer/plans" | jq ".plans | length"
20
Now create one. The viewer path refuses, on purpose. The refusal names the role you need.
curl -s -w "\nHTTP %{http_code}\n" -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" "$LZM_BASE/viewer/plans" -d '{"name":"REST tutorial plan","jql":"project = TPP"}'
{"error":"\"createPlan\" needs an editor token; this token is viewer","reason":"no-permission","needsRole":"editor","resolver":"createPlan"}
HTTP 403
The editor path does it. TPP is a 54-issue test project on our site, so put your own key in. wait holds the call for up to 15 seconds while the plan indexes. defaultAccess: none keeps the plan to you until you share it.
curl -s -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" "$LZM_BASE/editor/plans" -d '{"name":"REST tutorial plan","jql":"project = TPP","defaultAccess":"none","wait":15}' | tee plan.json | jq "{id: .plan.id, status: .progress.status, issues: .progress.issueCount, settled: .progress.settled}"
{
"id": "plan-mur9evwr-hnlo0y",
"status": "indexed",
"issues": 54,
"settled": true
}
You should see issues match what Jira's own search returns for that JQL as the same person. Mine did: Jira counted 54 for project = TPP. A plan only holds what its author can browse, so a project you can't see comes back as 0 issues and looks exactly like a JQL that matched nothing. If you get 0, check your access before your query.
Then ask the plan the question a sponsor would. The health check is a pure read. Nothing gets written or audited.
export PLAN_ID=$(jq -r .plan.id plan.json)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/viewer/health?planId=$PLAN_ID" | jq "{word: .sheet.word, coverage: .sheet.coverage | {items, dated, undated, share}, findings: [.sheet.findings[] | \"\(.count) \(.title)\"]}"
{
"word": "not-yet",
"coverage": {
"items": 48,
"dated": 5,
"undated": 43,
"share": 0.104
},
"findings": [
"3 Parents disagree with their Jira dates",
"32 Open work items have nobody assigned",
"54 Work items can't store Duration or Buffer in Jira",
"1 No baseline",
"1 No release date"
]
}
not-yet is the honest answer when only 5 of the 48 open work items have a start and a due date of their own. Fair. The 48 isn't issues. It's the open work items, the issues with no children in the plan, minus the ones already done. So it won't match the 54 indexed issues. The confidence score tells the same story out of 100.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/viewer/confidence?planId=$PLAN_ID" | jq -c "{score: .confidence.score, factors: [.confidence.factors[] | \"\(.id) \(.points)/\(.max)\"]}"
{"score":33,"factors":["dated 3/30","linked 15/15","simulable 0/15","consistent 10/15","owned 0/10","track 5/15"]}
Six checks. 3 + 15 + 0 + 10 + 0 + 5 is 33. This is the part a nightly job is for. Post the score to a channel, or fail a release check when it drops. What the plans, the health check and the forecast do inside the app is in LeanZero Management vs the Jira PPM field.
Clean up. Deleting is an admin action, so the editor path refuses and the admin path works.
curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/editor/plans?id=$PLAN_ID"
curl -s -w "\nHTTP %{http_code}\n" -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/admin/plans?id=$PLAN_ID"
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $ACCESS_TOKEN" "$LZM_BASE/viewer/plans?id=$PLAN_ID"
{"error":"\"deletePlan\" needs an admin token; this token is editor","reason":"no-permission","needsRole":"admin","resolver":"deletePlan"}
HTTP 403
{}
HTTP 200
{"error":"Plan not found"}
HTTP 404
The last 404 is how you confirm the plan is really gone. Done.
Errors you will hit: "REST APIs are not allowed for appId", "Missing required scopes for path" and needsRole
Every one of these came back during this run. Some are plain text and some are JSON, so don't pipe every answer straight into jq and wonder why it says parse error. (I did, on the first one.)
| what you get | what it means | the fix |
|---|---|---|
403 REST APIs are not allowed for appId=…, envId=…
|
the App REST APIs switch is off for that install | Connected apps, the Jira install's View app details, Enable app REST APIs |
403 Missing required scopes for path=/viewer/whoami, method=GET. Missing: [read:plan:custom]
|
the consent had no sns, so the app's scopes were dropped |
rebuild the authorize URL with sns=<app-id>.<env-id> and consent again |
403 "createPlan" needs an editor token; this token is viewer
|
the path tier is below the action | call /editor/… (or /admin/… for deletes) with the matching scope granted |
401 {"code":401,"message":"Unauthorized"}
|
no valid token reached the gateway: missing, mistyped, or (my bet for a nightly job) expired | send Authorization: Bearer <access token>, refreshed if it's older than an hour |
404 No matching API route found for path: /owner/plans
|
the tier isn't viewer, editor or admin | fix the path |
404 No extensions found in response
|
the environment id in the URL isn't installed on this site | copy the base URL from Connected apps or API Access again |
invalid_grant authorization_code is invalid
|
the code was already used | consent again for a fresh code |
The one I didn't get is 401 Unauthorized; scope does not match. Others have. In the developer community thread it had two causes: the Atlassian staff member who answered hit it himself until he added sns, and the person asking still had it until they added read:forge-app:confluence in the developer console.
Two app-side refusals didn't come up here, but they exist. A plan over a project the person can't browse answers 403 with jira-browse. When Jira can't confirm someone's access in time, the app answers 503 with jira-unverifiable and a Retry-After header. Retry that one.
What I ran this against, and what it doesn't cover
Every call above went to the development build (8.328.0) on wolfaenpak, our test site, signed in as a Jira admin there. Production exposes the same three tiers from the same apiRoute module, behind the same kind of URL; its build only drops the development-only web-trigger door. The production API Access screen above is real. I didn't make a production call for this article, because our demo site has no 3LO integration and I wasn't going to add one for a screenshot.
App REST APIs are a Preview at Atlassian. Their definition is worth reading once. "Preview features are deemed stable; however, they remain under active development and may be subject to shorter deprecation windows". The read:forge-app:jira behaviour above is exactly what can change under a Preview. Keep the scope.
Some things aren't on the API at all: Jira-admin configuration, Confluence publishing and token management. Importing Jira Plans needs Administer Jira, and I didn't run it here.
If you've used CogniRunner's Rules API, that one works differently: you mint an app token in its settings, as the 3.3.0 write-up shows. LeanZero Management went the 3LO way because its other door, a dynamic web trigger, isn't eligible for Atlassian's Runs on Atlassian program (Atlassian's web trigger page says so), and we wanted production to keep that eligibility. Does your org restrict what Forge apps may touch? The Forge app access rule tutorial covers the admin control next to this switch. The app itself, and what the plans look like in the UI, is on the LeanZero Management page.
Recap
[[takeaways]]
- You have a curl session with a refreshable 3LO token that creates, reads, health-checks and deletes a LeanZero Management plan, acting as the person who consented.
- The App REST APIs switch is per product install. Check the Jira install, not just the first row you open.
- Put
sns=<app-id>.<env-id>andoffline_accesson the authorize URL. Withoutsnsthe app's scopes vanish quietly and the first call answers 403 Missing required scopes. - The path tier is the role: viewer reads, editor changes, admin deletes. A refusal names the role you need.
- Store the new refresh token on every refresh. They rotate, and Atlassian gives you a 10-minute leeway, not a license.
- Not covered: a production call, Jira Plans import, Confluence publishing, and sharing the integration so other people can consent.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.




Top comments (0)