Putting the HTTP verb inside headers compiles clean under --strict and sets no method at all. A requestConfluence call shaped that way has been sitting in 69718 since May 2023, unremarked, because the thread was about something else. Judd hit a proxy error on the Confluence attachments API, HeyJoe traced it to an Atlassian-side networking change and had it rolled back the same day, Judd confirmed the fix. The code had been running fine in production, so nobody had reason to read the options object:
const response = await api.asUser().requestConfluence(
route`/wiki/rest/api/content/${pageId}/child/attachment/${imgId}/download`,
{
headers: {
'method': 'HEAD'
}
}
);
@forge/api declares method as a sibling of headers, never a key in it. That has been true in every major: 8.0.4 declares its own RequestInit, and 2.15.3 and 4.0.0 take it from node-fetch (out/index.d.ts line 1), where method is also a top-level field. The call was as wrong in 2023 as it is now.
The type
@forge/api 8.0.4, out/index.d.ts:
export interface RequestInit {
body?: ArrayBuffer | string | URLSearchParams;
headers?: Record<string, string>;
method?: string;
redirect?: RequestRedirect;
signal?: AbortSignal;
}
Five fields. headers is a Record<string, string>, and an index signature makes any string a legal key, so excess-property checking stops at that boundary. The compiler still checks the values, headers: { method: 5 } is TS2322, but it has no opinion left about the keys, so a verb in there is not a typo it can see.
What tsc does catch
I installed @forge/api@8.0.4 and typescript@5.9.3 into an empty project and ran tsc --strict --noEmit --skipLibCheck over three option shapes:
api.asApp().requestJira(r, { headers: { method: 'HEAD' } }); // no error
api.asApp().requestJira(r, { methd: 'HEAD' }); // TS2561
api.asApp().requestJira(r, { queryParams: { a: '1' } }); // TS2353
The bottom two are the controls, and they only fire because they are fresh object literals at the call site. Hoist the identical object into a const first and both compile clean, so those two are a coding-style away from silent as well. The header bag is the one that no style gets you out of.
I did not invent queryParams, incidentally. It is the variable name Atlassian’s own reference page gives a URLSearchParams before interpolating it into the route template, and in 79216 somebody lifted it out of the template into the options bag, where it sits next to headers and does nothing.
What the runtime then does
The client half I can show instead of argue. Here is parseRequest in @forge/bridge 6.3.0, out/fetch/fetch.js, with the downlevelled init guards folded back into optional chaining so it is readable:
const req = new Request('', { body: requestBody, method: init?.method, headers: init?.headers });
That req never leaves the function. It exists to normalise the headers and to decide whether there is a body to read, and with the verb in the header bag init.method is undefined, so it comes out GET and reads no body. What crosses the bridge is the payload productFetchApi builds around it, and for the 69718 options that payload carries "headers":[["method","HEAD"],["x-atlassian-token","no-check"]] and no method key at all. No verb means GET, and on an attachment /download route that is the difference between a few response headers and the whole file.
The backend path I can only take part of the way. @forge/api hands init to global.__forge_fetch__ unchanged, and the only .headers read anywhere in its shipped JavaScript is response.headers.get('forge-proxy-error'). Every other mention is the package assembling headers for a request of its own, in the GraphQL, web-trigger and personal-data helpers. Your init.headers is never inspected. What Atlassian’s proxy makes of a stray header named method, I did not observe.
The two requestJiras are not typed the same
@forge/bridge declares no RequestInit of its own. out/types.d.ts says export type ForgeRequestInit = Omit<RequestInit, 'signal'>, meaning the global one, whichever your compiler options put in scope. For a Custom UI front end that is the DOM’s. Same three shapes, same tsc:
option @forge/api @forge/bridge
headers: { method: 'HEAD' } compiles compiles
cache: 'no-store' TS2353 compiles
queryParams: { a: '1' } TS2353 TS2353
cache type-checks in a Custom UI file and stops compiling the moment you move the call into a resolver. I assumed the bridge would then drop it, and it does not:
fetchRequestInit: {
...validatedInit,
body: requestBody,
headers: [...requestHeaders.entries()]
}
Your whole options object is spread through with only body and headers overwritten, so cache, redirect and an invented key I made up all reached the stub I put on the other side. The one option @forge/bridge strips is signal, and it logs a console.error when it does.
I think the reference page could do more here. Its options row is a link to Using the Fetch API with Undici in Node.js “for details about accepted values”, which is a strictly wider surface than these five fields, and redirect and signal appear on neither page. A table naming the five would cost little.
One thing I did not check: what the platform proxy does with the stray header. If you have a Forge app with any age on it, grep -rn -A5 "headers:" src | grep -i method is a two-second look. The two keys almost always land on separate lines, so grepping for both on one line finds nothing.