The instruction was explicit: use api.asApp().requestJira with a route template literal and the correct Jira Cloud REST path for a JQL search, and handle pagination. Here is what came back, unedited:
Four things in that are wrong in August 2026: the path, the startAt pagination, the read of data.total, and the missing fields parameter. Only the first one stops the function, and it does not stop it cleanly — the 410 body has no issues key, so data.issues.length throws TypeError: Cannot read properties of undefined (reading 'length'), which names none of the other three. Fix the path and the remaining three go quiet.
The model was qwen3.8-27b-brainwaves-mxfp8-mlx, loaded across the three LM Link nodes our goose fork's swarm runs on — I prompted it directly through LM Studio rather than through goose itself. I ran that prompt four times; the answer above is the fourth. Counting every REST path in all four replies: /rest/api/3/search appeared in all four, /rest/api/2/search once, and /rest/api/3/search/jql not once. That is not a knock on the model. I did not measure the corpus, so treat this as inference rather than a finding: the old call was the only way to do this for years, the replacement arrived in 2025, and every coding agent you point at a Forge app is drawing from the same well. If you are building an app that lets a model write Jira calls at runtime, this is the failure mode you are shipping — it is why the LLM-powered Forge app tutorial hands the model a documentation bundle instead of trusting recall.
So I deployed a Forge app to a test site to find out exactly what each of those wrong things does. Everything below is its output.
What the removed endpoint does now
GET /rest/api/2/search, GET /rest/api/3/search and POST /rest/api/3/search all return HTTP 410 Gone:
json
1{2"errorMessages":[3"The requested API has been removed. Please migrate to the /rest/api/3/search/jql API. A full migration guideline is available at https://developer.atlassian.com/changelog/#CHANGE-2046"4],5"errors":{}6}
You will find notes and forum posts saying this endpoint returns HTTP 200 with that error body — which would be far nastier, because a res.ok check would sail straight past it. I had one such note myself. On 26 August 2026 I re-ran it four ways: anonymously against two different Cloud sites, authenticated with an API token, and from inside a deployed Forge resolver. Every one returned 410. From inside the Forge runtime:
If you have a note that says 200, re-check it before you build a workaround around it. The shutdown was progressive — announced under CHANGE-2046, rolled out between August and October 2025 — and a claim measured mid-rollout does not survive the rollout finishing.
This is the good news in the whole piece. ok is false, status is 410, and any app with a if (!res.ok) throw gate fails on the first call and gets fixed the same afternoon. Nothing below is that kind.
Silent failure 1: the one-line migration loses every field
The obvious repair is to change the path and touch nothing else. It compiles, it deploys, and it returns HTTP 200. Here is what came back for project = LZPT ORDER BY created ASC with maxResults=5, from inside the resolver:
Read that carefully. The status is 200. The issues array has exactly the five entries you asked for, so a length check passes and a "did we get results?" guard passes. And every issue in it is {"id": "21778"} — no key, no fields, no self.
/search/jql defaults to fields=id. The clearest evidence is the published OpenAPI spec, where the two endpoints carry near-identical fields descriptions with opposite defaults. The removed one:
Note: All navigable fields are returned by default. This differs from GET issue where the default is all fields.
And its replacement:
The default is id. … Note: By default, this resource returns IDs only. This differs from GET issue where the default is all fields.
Same closing sentence, inverted premise. In the machine-readable schema the old parameter carries "default": "*navigable" and the new one carries none at all. Nothing in the path change signals it, which is the entire problem: the diff is /search → /search/jql, and the behaviour change is in a default you never wrote down.
So issue.fields.summary is a TypeError on undefined, and issue.fields?.summary — which is what defensive code actually looks like — is undefined, forever, with no error anywhere. An app that renders a table of issues now renders the right number of rows, all blank. That is the single worst outcome available here, and it is what the one-line migration buys you.
The fix is one parameter, and you have to pass it every time:
fields
what you get back per issue
omitted
id only
id
id only
key
id, key, self, expand — and no fields object at all
summary,status
id, key, self, expand, and fields with exactly those two
*navigable
43 fields on our test issues
*all
48 fields on our test issues
7 rows × 2 columnsHeader row enabled
*navigable and *all still work, so fields=*navigable restores the old default behaviour exactly. Do not reach for it out of habit — the whole point of an id-only default is that most apps were shipping 43 fields to render three of them, and the per-page cap scales with how much you ask for.
Silent failure 2: startAt is accepted and ignored
startAt is not in the /search/jql parameter list at all. It is not deprecated, it is not documented as ignored — the spec simply does not have it. What the endpoint does with it is worse than rejecting it:
Same three issues at startAt=0 and startAt=50. HTTP 200 both times. I pushed on this: totallyBogusParam=1 and validateQuery=strict are also accepted with a 200 and no effect. The endpoint ignores unknown query parameters silently, as a class.
The practical consequence depends on how your loop terminates. A loop that stops when a page comes back short — while (issues.length === maxResults) — will fetch page one forever, and on a Forge function that means you burn the invocation timeout and get a platform error with nothing useful in it. A loop that stops on total does something quieter, which is the next failure.
Silent failure 3: total does not exist
The old SearchResults schema carried expand, issues, maxResults, names, schema, startAt, total and warningMessages. The new SearchAndReconcileResults carries isLast, issues, names, nextPageToken, schema and warnings. Both startAt and total are gone.
So data.total is undefined, and in the generated code at the top of this article the guard is startAt + data.issues.length >= data.total. Any comparison against undefined is false, so that guard never fires and the loop never breaks — the startAt version of the same bug. But the more common shape in real code is the outer-loop version, and it fails the other way:
I ran exactly that against a live response. It executed zero iterations, collected zero issues, and threw nothing. The app reports an empty result set for a query that matches 58 issues, and every log line is green.
This is the failure that survives a code review, because the diff that caused it is one word in a URL and the symptom is somewhere else entirely — a dashboard that says "0" instead of erroring. It is the same class of problem as JQL counting a missing custom field as zero during a migration audit: the number is wrong rather than missing, and nothing is on fire.
The migration that works
Here is the whole app I deployed, because a tutorial that hands you a snippet and no manifest is asking you to guess the half that breaks.
src/index.js — the paging loop, with the parts the naive swap leaves out:
javascript
1importapi,{ route }from'@forge/api';2importResolverfrom'@forge/resolver';34asyncfunctionallIssues(jql, fields ='summary,status'){5let token =null;6const issues =[];78do{9const q = token
10? route`/rest/api/3/search/jql?jql=${jql}&maxResults=50&fields=${fields}&nextPageToken=${token}`11: route`/rest/api/3/search/jql?jql=${jql}&maxResults=50&fields=${fields}`;1213const res =await api.asApp().requestJira(q);14if(!res.ok)thrownewError(`search/jql ${res.status}`);1516const body =await res.json();17 issues.push(...(body.issues||[]));1819// isLast is authoritative. nextPageToken is simply absent on the last page.20 token = body.isLast?null: body.nextPageToken;21}while(token);2223return issues;24}2526const resolver =newResolver();27resolver.define('search',async({ payload })=>allIssues(payload.jql));28exportconst handler = resolver.getDefinitions();
And src/frontend/index.jsx, which the manifest's resources entry points at:
jsx
1importReact,{ useEffect, useState }from'react';2importForgeReconciler,{Text,CodeBlock,Heading,Spinner}from'@forge/react';3import{ invoke }from'@forge/bridge';45constApp=()=>{6const[data, setData]=useState(null);7useEffect(()=>{invoke('search',{jql:'project = LZPT ORDER BY created ASC'}).then(setData);},[]);8if(!data)return<Spinner/>;9return(10<>11<Headingas="h2">/rest/api/3/search/jql</Heading>12<CodeBlocktext={JSON.stringify(data,null,2)}language="json"/>13<Text>Run from inside a Forge resolver on this site.</Text>14</>15);16};17ForgeReconciler.render(<React.StrictMode><App/></React.StrictMode>);
Three things in that loop are load-bearing:
fields is passed explicitly. Without it you get ids.
The loop keys on isLast, not on a short page. A page can come back shorter than maxResults without being the last one — the spec says the API may return fewer items when you request a lot of fields.
nextPageToken is absent, not null, on the last page.body.nextPageToken is undefined there, so a !== null check is wrong and a truthiness check is right.
route handles the encoding, and it is worth being precise about that because getting it wrong produces an error that points somewhere else entirely. The JQL in my probe was project = LZPT ORDER BY created ASC — spaces, an =, and all — interpolated straight into the template with no encodeURIComponent, and it came back 200 with both requested fields populated. The comma in fields = 'summary,status' survives interpolation the same way; the response carried status and summary.
Wrap it in encodeURIComponent first and the same call returns HTTP 400 with this:
text
1Error in the JQL Query: The character '%' is a reserved JQL character.
2You must enclose it in a string or use the escape '\u0025' instead. (line 1, character 8)
That message will send you looking at your JQL, which is fine. The problem is your encoding. Interpolating into routeis the encoding.
Deployed and run against 58 issues, that loop reports:
58 of 58 issues arrived with a populated summary — which is the assertion that the naive version fails, and the one worth putting in a test. I checked it rendered in the product too rather than trusting the JSON, using the same approach as running real end-to-end UI tests against a deployed Forge app.
Tip
While building the page I hit an unrelated UI Kit trap worth thirty seconds of your time: <Code> is the inline component. Passing a multi-line JSON string to it renders a collapsed sliver a few pixels tall, with no error. <CodeBlock> is the block-level one. Both are exported from @forge/react, both take text and language, and only one of them is what you meant.
The upgrade nobody mentions: maxResults goes to 5000
The documented default is 50, and the old endpoint is gone so I cannot measure what its ceiling was. What I can measure is the new one's. I walked maxResults up against an 8,556-issue query and checked unique keys each time, because an API that claimed 5,000 issues and returned 5,000 copies of one would look identical in a length check:
requested
result
100
200 OK, 100 issues, 100 unique
1000
200 OK, 1000 issues, 1000 unique
5000
200 OK, 5000 issues, 5000 unique
5001
400 — "The max results parameter has to be between 1 and 5,000."
5 rows × 2 columnsHeader row enabled
A hundred times the default page size, for the cost of passing the parameter. The spec's own wording is that the ceiling is achieved "when requesting id or key only" — ask for 40 fields and you will get fewer rows per page than you asked for, which is precisely why the loop above keys on isLast rather than on page length. If you are pulling tens of thousands of issues this is the difference between 100 round trips and 2, and it interacts directly with Atlassian's points-based rate limits — fewer, fatter requests is the shape that budget rewards.
One more thing the spec says that is easy to miss: nextPageToken expires after 7 days. If you are checkpointing a long crawl and resuming it, the token is not a durable cursor.
Counting, now that total is gone
POST /rest/api/3/search/approximate-count with {"jql": "..."} returns {"count": N}. I compared it against a complete page-through on three queries:
JQL
approximate-count
paged
match
project = LZPT
58
58
yes
created >= "2000-01-01"
8556
8556
yes
statusCategory != Done
7538
7538
yes
4 rows × 4 columnsHeader row enabled
Exact all three times, including at 8,556. Do not turn that into an invariant. The endpoint is named approximate-count, Atlassian documents it as an estimate, and I caught it being stale myself: immediately after my probe deleted the five test issues it had created, it still returned 59 for a project holding 58. A page-through a few seconds later returned exactly 58, and so did the count on retry. If your UI shows a number next to a list, that number and that list can disagree. If a business rule branches on a count, page and count the results yourself.
Read-after-write: the one that will bite a post-function
Buried in the /search/jql description is a reconcileIssues parameter and a sentence about read-after-write consistency. This endpoint is eventually consistent, and if your app creates an issue and then searches for it — which is most of what a workflow post-function does — the gap is the whole story.
So I measured it. The probe creates an issue, then polls two searches in the same tick: one plain, one with reconcileIssues=<the new issue id>. Five runs, all on the same site:
run
plain search first saw it
with reconcileIssues
1
2988 ms
550 ms
2
2916 ms
487 ms
3
1639 ms
448 ms
4
1668 ms
454 ms
5
1657 ms
467 ms
6 rows × 3 columnsHeader row enabled
The reconcileIssues column is not "faster" — it is the first sample in every run, roughly one API round trip after the create, which means the issue was already there whenever it was first asked for. The plain search returned zero on that first sample in all five runs, and stayed at zero for between 1.6 and 3.0 seconds.
A post-function that creates a linked issue and then re-queries to count what it just made will get the count from before its own write. Not sometimes — in five out of five runs here, at the timescale a post-function actually runs at. reconcileIssues takes up to 50 issue ids and, per the spec, the list has to stay consistent across every page of a paginated request.
javascript
1const created =awaitcreateIssue(...);2const res =await api.asApp().requestJira(3 route`/rest/api/3/search/jql?jql=${jql}&fields=key&reconcileIssues=${created.id}`4);
My polling resolution was about 1.2 seconds per sample, so treat 1.6–3.0 s as a band and not a precise latency. The direction is not ambiguous.
Bounded JQL, and what counts as bounded
/search/jql refuses an unbounded query with a 400:
json
1{"errorMessages":["Unbounded JQL queries are not allowed here. Please add a search restriction to your query."]}
The removed endpoint had no such requirement — its own spec says "If no JQL expression is provided, all issues are returned" — and I cannot re-measure that now, so take it from the documentation rather than from me. The new one's definition of bounded is "a query with a search restriction", which is thin, so I tested the boundary:
JQL
result
order by created DESC
400 unbounded
project = LZPT
200
created >= -30d
200
status != Done
200
assignee is not EMPTY
200
text ~ "test"
200
issuekey > LZPT-1
200
type = Task
200
9 rows × 2 columnsHeader row enabled
Any clause at all is enough, including negations and is not EMPTY, which match nearly everything. Only a bare ORDER BY is rejected. If your app builds JQL from user filters and the user clears them all, you go from "all issues" to a 400 — worth a guard, since it is the one path in this whole migration that fails loudly at the worst moment.
What I checked in our own apps
Grepping our five Forge apps for the old path found zero call sites. Four of them search Jira at all; all four are on /search/jql and all four pass fields explicitly. The fifth never searches. Migrated, and migrated correctly.
One fossil survived, in our own PPM app's paging loop:
data.total is always undefined on this endpoint, so the || totalFetched fallback carries it and the app works. It works by accident. Anyone reading that line for a pattern to copy would reasonably conclude data.total is a thing that exists. Dead reads like that are worth deleting precisely because they teach the wrong lesson to the next person in the file.
What I did not test
Being straight about the edges:
Everything here was measured on one Cloud site. The per-project probes ran against a team-managed Jira Software project; the site-wide ones span that site's Jira Software and Jira Service Management projects. I did not test a Jira Product Discovery project, a Data Center instance, or a site with issue-level security in play.
The warnings array in the response schema is documented as experimental and as the channel for "the result set was truncated due to an ingestion limit". I never saw it populated, so I cannot tell you what a truncation looks like in practice — only that a silent truncation channel exists and you should log the array if you see it.
I did not measure whether the 7-day nextPageToken expiry surfaces as a 400 or a 404.
The read-after-write numbers are five runs on one site at one time of day. They establish that the gap is real and that reconcileIssues closes it. They are not a latency SLO.
If you take one thing: the 410 is not your problem. It is loud, it is obvious, and it gets fixed. Your problem is the app that was migrated last year by someone who changed the path, saw HTTP 200, and shipped.
notifyUsers=false in Jira Cloud: four ways your test bed fakes a pass