Skip to content

HTTP API ​

The HTTP API is the platform contract for machines: builds, releases, store submissions and project visibility over https://www.extension.dev. Reach for it when a pipeline or a script needs to do what the console does.

Quick start ​

bash
curl -s -X POST https://www.extension.dev/api/projects/$PROJECT_ID/builds/dispatch \
  -H "Authorization: Bearer $EXTENSION_DEV_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"origin":"api"}'

The build starts on the project's build repository and lands under the api usage label. The call needs a project token.

How it works ​

Two credentials ​

A project token goes in the header:

Authorization: Bearer <project token>

The other credential is a signed-in browser session, which is what the console uses. Most of this API takes either one. Six verbs take the session only, and the Reference names each with the reason.

A request that carries a token is judged on the token and never falls back to the session. If you hold a session and send a bad token in the same request, you get the token's refusal. A token sent as ?token= is refused at every door that reads the header, and is never accepted.

Owner only ​

Six token actions are owner only: POST /releases/cut, GET /releases/promote-ready, GET /releases/promotion-runs, POST /releases/{channel}/rollback, DELETE /releases/{channel} and POST /stores/{store}/submissions. Owner only asks about the member who minted the token, and it asks the workspace at every call. A member cannot buy an owner's action by minting their own token, and demoting a member disarms every token that member left behind. The refusal is 403 OWNER_REQUIRED.

What whoami tells you ​

GET /api/cli/whoami answers every refusal with the same bytes: 401 with Valid access token required. and no code field. No token, a malformed one, an expired one, a revoked one and a token in the query string all leave with that one sentence. Separate answers would tell a stranger which of those they were holding. Call whoami to learn whether a token is live, never to learn what is wrong with it.

The OIDC exchange is not an API credential ​

POST /api/oidc/exchange trades a GitHub Actions OIDC assertion for a one-hour registry token. The exchange records no token against the project, and every endpoint on this page checks that record. So the token it returns is refused 401 REGISTRY_TOKEN everywhere on /api/projects/{id}/... and on the /api/cli endpoints.

The 200 says so itself: it carries lane: "registry" beside the token and a note naming the credential this API takes instead. For a pipeline that calls this API, mint a project token and store it as a secret.

Approvals ​

Every real store submission or promote through /api/cli needs a token minted by a workspace owner and a human approval of that exact call. The first call answers 403 APPROVAL_REQUIRED with an approvalRequest (the action, scope and fingerprint to send). Create an approval at POST /api/cli/approvals with it, have a person approve it at the URL the answer carries, then repeat the call with the same approvalId. Only a workspace owner can approve a submission or a promote, and the call checks again that the person who approved is still an owner when it uses the approval. A promote from the console needs no approval and stays open to any member. A dry run never needs one, and a call the platform would refuse anyway answers that refusal first, marked refusedBeforeApproval.

What a build costs ​

A build the API starts is never counted against the push build ceiling. A build from POST /api/projects/{id}/builds/dispatch lands under the usage label its origin maps to, and a store submission from POST /api/cli/stores/submit lands under api. See why a build started.

What bounds the API is the monthly build allowance and the burst window, see extension.dev/pricing. The one plan gate is Safari, which needs a paid plan, named in the error contract.

Reference ​

What a project token reaches ​

AreaA project token reachesSession only
BuildsEvery read, and POST /builds/dispatchnothing
ReleasesEvery read, POST /releases, DELETE /releases/{channel}, GET and POST /releases/cut, GET /releases/promote-ready, GET /releases/promotion-runs, POST /releases/{channel}/rollbacknothing
StoresGET /stores/config, GET /stores/status, GET and POST /stores/{store}/submissions, POST /stores/test/{store}PUT and PATCH /stores/config, POST /stores/health, POST /stores/chrome/rollout, POST /stores/chrome/submissions/{submissionId}/cancel
Project visibilityGET /visibilityPUT /visibility
Access tokensnothingGET and POST /access-tokens, DELETE /access-tokens/{tokenId}
CLIPOST /api/cli/publish, POST /api/cli/stores/submit, POST /api/cli/release/promote, GET /api/cli/whoami, POST /api/cli/approvals, GET /api/cli/approvals/{approvalId}nothing

Token refusals ​

Every one of these carries a code field.

CodeStatusMeaning
TOKEN_IN_QUERY401The token arrived as ?token=. Send the header
BAD_TOKEN401The token does not verify, or carries no project
TOKEN_NOT_TRACKED401The project keeps no record of this token
REGISTRY_TOKEN401The token came from the OIDC exchange and reads the registry only
PROVISION_TOKEN401The token is a provisioning grant for POST /api/cli/projects/create and opens no project door
WORKSPACE_PROVISION_TOKEN401The token is a provisioning grant for POST /api/cli/workspaces/create and names no project
TOKEN_REVOKED401The record says revoked
TOKEN_PROJECT_MISMATCH403The token names a different project
SHARE_TOKEN403This is a share link, and share links are read only
TOKEN_ISSUER_UNKNOWN403The record names no minter, so nobody is accountable
FORBIDDEN403The minter has left this workspace
OWNER_REQUIRED403The minter is not an owner
APPROVAL_REQUIRED403The call needs a human approval and no approvalId was sent
APPROVER_NOT_OWNER403The approval needs an owner, and whoever approved it is not an owner now. The approval is not used
PROJECT_NOT_FOUND404The token's project is gone

What a token never reaches ​

Six verbs read a signed-in session and nothing else. They do not read the Authorization header at all: a request carrying a token answers, byte for byte, what a request carrying nothing answers. Reach them from the console.

VerbWhy it is session only
GET, POST /api/projects/{id}/access-tokensA credential that mints credentials is a lease that renews itself forever, and the list is the revocation handle for every other token
DELETE /api/projects/{id}/access-tokens/{tokenId}The same reason
PUT /api/projects/{id}/visibilityFlipping private to public discloses the whole build history in one request, and putting it back does not un-download anything
PUT and PATCH /api/projects/{id}/stores/configA leaked token that could repoint storeId and publisherId would publish your extension under somebody else's listing

The session refusal comes back in two shapes, and a machine should handle both:

ShapeWhere
401 {"message":"You must be signed in.","code":"UNAUTHENTICATED"}Builds, releases, stores, the visibility read
401 {"message":"Not authenticated"}, with no code fieldAccess tokens, and the visibility write

Device flow ​

RFC 8628. Approval happens at www.extension.dev/device.

RouteBodyAnswer
GET /api/cli/login/confignone, no authDevice flow discovery
POST /api/cli/device/code{project, clientName?}A user code with a 15 minute TTL. Poll every 5 seconds
POST /api/cli/device/token{device_code}The token, or an RFC 8628 error: authorization_pending, slow_down, expired_token, access_denied

Token exchange ​

RouteBodyAnswer
POST /api/oidc/exchange{token, projectId?}A one-hour registry token with lane: "registry" and a note. A missing token answers 400, a token that does not verify answers 401 with a reason

The assertion must be minted for the audience extension-dev-registry. The audience is the assertion's, not the returned token's.

This is a registry credential, not an API credential.

Use it to read the registry. Every door on this page refuses it with 401 REGISTRY_TOKEN, a code of its own rather than the TOKEN_NOT_TRACKED a deleted record answers.

CLI actions ​

RouteBodyWhat it does
POST /api/cli/publish{ttlHours?, buildSha?}Returns the project's build page URL: the canonical public page for a public project, a ?share= tokenized link for a private one, whose lifetime is ttlHours: 7 days by default, at most the plan's share window, 30 days on Free and 365 days on Pro. It uploads nothing and is not store submission
POST /api/cli/stores/submit{browsers[], buildSha, channel?, version?, dryRun?, origin?, approvalId?}Submits a build to the listed stores. channel defaults to stable. origin takes the same values as a dispatch and reads as cli when left out; the submission is counted under api either way
POST /api/cli/release/promote{buildId, channel, sourceChannel?, browsers?, approvalId?}Promotes a build to a channel, the same release POST /api/projects/{id}/releases creates
GET /api/cli/whoaminoneWhether the token is live
POST /api/cli/approvals{action, scope, fingerprint, description?}Creates an approval and answers 201 with approvalId and approvalUrl. action is extension_submit, extension_release_promote or extension_shares.revoke
GET /api/cli/approvals/{approvalId}noneThe approval's status, or 404 APPROVAL_NOT_FOUND
POST /api/cli/projects/createa provisioning grantCreates the project the grant names
POST /api/cli/workspaces/createa workspace provisioning grantCreates the workspace the grant names

Access tokens ​

Session only, all three. See what a token never reaches.

RouteWhat it does
GET /api/projects/{id}/access-tokensList tokens
POST /api/projects/{id}/access-tokensMint a token, 1 to 7 days, default 7
DELETE /api/projects/{id}/access-tokens/{tokenId}Revoke a token. The project API refuses it on its next request. The artifact sharing path reads a cached copy of the record for up to five minutes and proceeds on the signature alone when the record cannot be read. See Access tokens

Builds ​

RouteBody or queryWhat it does
GET /api/projects/{id}/buildsList builds. A row read from a running or finished workflow run carries its runId, and deploymentId when the run was dispatched with one
GET /api/projects/{id}/builds/live-status?runId= required, 400 without itThe live status and conclusion of one workflow run, its start and finish times and its run URL
GET /api/projects/{id}/builds/{buildId}One build, with the runId, runUrl and startedAt of the run behind it, and deploymentId while that run is the one shown
GET /api/projects/{id}/builds/{buildId}/logsBuild logs
GET /api/projects/{id}/builds/{buildId}/artifactsBuild artifacts, launchers included
GET /api/projects/{id}/builds/{buildId}/usageThe build's USAGE.md, as markdown. Metered minutes and cost live in workspace settings, not here
GET /api/projects/{id}/builds/{buildId}/integrity/{browser}The integrity record for one browser's artifact
POST /api/projects/{id}/builds/dispatch{commitSha?, buildEnv?, force?, origin?}Starts a build on the build repository. origin decides the usage label

origin is one of console, cli, cli-unknown, mcp, code, import or api. Five older spellings, generation, templates, www, dashboard and ui, map onto those. Any other value answers 400 INVALID_ORIGIN, and leaving it out reads as console.

A rebuild of a commit that already built keeps the same build id, so the 200 from a dispatch names the run it started. Every answer carries deploymentId and dispatchedAt beside ok, branch, commit, browsers, strippedBrowsers and origin. A project token caller also gets runCorrelation, and runId and runUrl once the run is found:

runCorrelationMeaning
run-nameThe run carries this deploymentId in its name. runId and runUrl are this rebuild's run
windowThe build repository predates the deployment id, and exactly one run started within five seconds of dispatchedAt
pendingNo run carried the id yet. Read GET /builds/{buildId} and match its deploymentId, or a startedAt after dispatchedAt
ambiguousMore than one run matched, so none is named
unsupportedThe build repository predates the deployment id and no single run matched

Releases ​

RouteWhat it does
GET /api/projects/{id}/releasesList releases
POST /api/projects/{id}/releasesCreate a release: body {buildId, channel, sourceChannel?, browsers?}. On a free workspace the promote succeeds and reports notarization.refused
GET /api/projects/{id}/releases/{channel}One channel's release
DELETE /api/projects/{id}/releases/{channel}Remove the release row. Owner only; any other member answers 403 OWNER_REQUIRED
GET /api/projects/{id}/releases/{channel}/eventsThe channel's promotion events, newest first. Rollback resolves its target from this log
GET /api/projects/{id}/releases/promotion-runsThe promote runs behind the reported status. Owner only
POST /api/projects/{id}/releases/cutCut a release. Owner only. Opens the version-bump PR in the source repository
GET /api/projects/{id}/releases/cutWhat the cut would cost, as allowed, limit, used and dispatchDisabled. Read it before you cut
GET /api/projects/{id}/releases/promote-readyThe pending release's readiness, as state plus the cut's PR and version. Owner only. It reports whether the release is armed and does not arm it; arming is the on-merge switch on the cut. GET is the only verb: every other one answers 405 with Allow: GET, before any credential is read
POST /api/projects/{id}/releases/{channel}/rollbackRoll a channel back. Owner only. Refuses with 409 NO_ROLLBACK_TARGET, 503 ROLLBACK_HISTORY_UNREADABLE, 404 RELEASE_WORKFLOW_NOT_FOUND or 502 ROLLBACK_DISPATCH_FAILED

Release status ​

status on a release carries one of four values. A machine reading it must treat only the first as shipped.

ValueMeaningrollouthealth
liveEvery browser's promote run concluded success100%good
promotingA run is still going0%monitoring
failedA run concluded anything else0%degraded
unconfirmedNo run answers for that pointer0%unknown

Each browser row carries a promotion object with verified, runId, runUrl, runStatus and runConclusion, so a caller can check the verdict rather than trust it. When the run history cannot be read the values stay at whatever the record proves on its own, which is never live.

Stores ​

RouteWhat it does
GET /api/projects/{id}/stores/configStore configuration
PUT or PATCH /api/projects/{id}/stores/configUpdate store configuration. Owner only, and session only. The route takes GET, PUT and PATCH, so POST answers 405 before any credential is read
GET /api/projects/{id}/stores/statusSubmission status per store
GET /api/projects/{id}/stores/{store}/submissionsSubmission history
POST /api/projects/{id}/stores/{store}/submissionsStart a submission. Owner only. Takes origin like a dispatch, and Safari also takes submitForReview; any other store answers 400 REVIEW_INTENT_NOT_SUPPORTED to it
POST /api/projects/{id}/stores/test/{store}Test one store's credentials. {store} is chrome, edge, firefox or safari, each a route of its own. Any other name answers 404 as an HTML page rather than as JSON, because no route exists to answer it
POST /api/projects/{id}/stores/healthQueue a credential check on the build repository. Session only, owner only, counts as one dispatch
POST /api/projects/{id}/stores/chrome/rolloutRaise a staged Chrome rollout, 1 to 100 and higher than the current one. Session only, owner only. See Chrome
POST /api/projects/{id}/stores/chrome/submissions/{submissionId}/cancelPull a Chrome submission back from review while it reads submitted. Session only, owner only

Submission status ​

status is one of draft, dry_run, pending, submitted, approved, rejected, failed, unknown, and the same eight words are used for every store. Only submitted says a review round started. See Publishing.

A machine reading this must not treat draft or unknown as a review round. draft is a package uploaded with nothing submitted, and unknown is what the platform records when the run left no readable state.

Project visibility ​

RouteBodyAnswer
GET /api/projects/{id}/visibilityvisibility, registry and effective: the recorded setting, what the two indexes say, and what the registry answered a request carrying no credential. They can disagree, which is why all three come back
PUT /api/projects/{id}/visibility{"visibility":"public"} or {"visibility":"private"}Session only. Any other value answers 400. A caller who is not an owner or an admin of the workspace answers 403. The answer carries projectId, visibility, indexPublished and metaRepublished

indexPublished: false means the setting is stored and the registry did not take it, so the gate does not match the setting and nothing retries on its own. Read it; a 200 alone is not enforcement.

Error contract ​

RouteStatusCodeMeaning
POST /api/cli/stores/submit503, Retry-After: 10The platform cannot take the submission. Nothing was dispatched and nothing was recorded. Retry the same call unchanged
POST /api/cli/stores/submit404UNKNOWN_BUILDThe sha is not in the build index. Push, or dispatch a build for that commit, before submitting it
POST /api/cli/stores/submit409SAFE_MODE_INPUT_UNSUPPORTEDThe store workflow predates the safe default. Save the project settings to resync it
POST /api/cli/stores/submit429BUILD_DISPATCH_DISABLEDThe operator paused dispatch. Try again later
POST /api/cli/stores/submit, POST /api/projects/{id}/stores/safari/submissions403MACOS_RUNNER_NOT_INCLUDEDA Safari submission on a free workspace. Nothing is dispatched and no allowance is spent
The same two403SAFARI_SUBMISSIONS_EXHAUSTEDThe sixteenth App Store submission of the month on a paid workspace
The same two503SAFARI_ALLOWANCE_UNAVAILABLEThe Safari counter could not be read
POST /api/projects/{id}/releases200MACOS_NOTARIZATION_NOT_INCLUDED under notarization.refusedSigning the macOS launcher is paid. The promote succeeds, every extension package publishes, and the Linux and Windows executables are still built

Safari and the App Store are paid together, builds and submissions, because the submit job runs on a macOS runner. A paid workspace carries 15 App Store submissions a month. Chrome, Edge and Firefox submissions run on Linux and are never refused either way. See promote gates for the full rule and all twelve quota refusals.

Preflight ​

dryRun: true on POST /api/cli/stores/submit runs the preflight. Nothing dispatches.

FieldMeaning
storeThe store this verdict is about
configuredThe store has credentials, or unknown when no health was published
healthyThe credentials pass the health probe, or unknown
okThe store would accept this submission
reasonWhy a blocked store is blocked
consoleUrlThe console page that fixes that store
checkedAtWhen the health probe last ran

A dry run that names Firefox also carries a FIREFOX_DATA_COLLECTION_PERMISSIONS warning. See Firefox.

When it fails ​

  • 401 with a code on a token call. Read the code in token refusals. BAD_TOKEN and TOKEN_NOT_TRACKED mean mint a new token. REGISTRY_TOKEN means you sent the OIDC exchange's credential.
  • 401 with no code on a token call. The verb is session only. Reach it from the console.
  • 403 OWNER_REQUIRED. The token was minted by a member who is not a workspace owner. An owner mints the token for that action.
  • 403 APPROVAL_REQUIRED. Create an approval at POST /api/cli/approvals from the approvalRequest in the answer, have a person approve it, and repeat the call with its approvalId.
  • 400 INVALID_ORIGIN. The origin is not one of the seven values or five older spellings. Use api from a pipeline.
  • 400 Missing buildSha. The body was not read as JSON. Send Content-Type: application/json.
  • 404 UNKNOWN_BUILD. Dispatch or push a build for that commit, then submit it.

Next ​