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
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
| Area | A project token reaches | Session only |
|---|---|---|
| Builds | Every read, and POST /builds/dispatch | nothing |
| Releases | Every read, POST /releases, DELETE /releases/{channel}, GET and POST /releases/cut, GET /releases/promote-ready, GET /releases/promotion-runs, POST /releases/{channel}/rollback | nothing |
| Stores | GET /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 visibility | GET /visibility | PUT /visibility |
| Access tokens | nothing | GET and POST /access-tokens, DELETE /access-tokens/{tokenId} |
| CLI | POST /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.
| Code | Status | Meaning |
|---|---|---|
TOKEN_IN_QUERY | 401 | The token arrived as ?token=. Send the header |
BAD_TOKEN | 401 | The token does not verify, or carries no project |
TOKEN_NOT_TRACKED | 401 | The project keeps no record of this token |
REGISTRY_TOKEN | 401 | The token came from the OIDC exchange and reads the registry only |
PROVISION_TOKEN | 401 | The token is a provisioning grant for POST /api/cli/projects/create and opens no project door |
WORKSPACE_PROVISION_TOKEN | 401 | The token is a provisioning grant for POST /api/cli/workspaces/create and names no project |
TOKEN_REVOKED | 401 | The record says revoked |
TOKEN_PROJECT_MISMATCH | 403 | The token names a different project |
SHARE_TOKEN | 403 | This is a share link, and share links are read only |
TOKEN_ISSUER_UNKNOWN | 403 | The record names no minter, so nobody is accountable |
FORBIDDEN | 403 | The minter has left this workspace |
OWNER_REQUIRED | 403 | The minter is not an owner |
APPROVAL_REQUIRED | 403 | The call needs a human approval and no approvalId was sent |
APPROVER_NOT_OWNER | 403 | The approval needs an owner, and whoever approved it is not an owner now. The approval is not used |
PROJECT_NOT_FOUND | 404 | The 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.
| Verb | Why it is session only |
|---|---|
GET, POST /api/projects/{id}/access-tokens | A 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}/visibility | Flipping 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/config | A 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:
| Shape | Where |
|---|---|
401 {"message":"You must be signed in.","code":"UNAUTHENTICATED"} | Builds, releases, stores, the visibility read |
401 {"message":"Not authenticated"}, with no code field | Access tokens, and the visibility write |
Device flow
RFC 8628. Approval happens at www.extension.dev/device.
| Route | Body | Answer |
|---|---|---|
GET /api/cli/login/config | none, no auth | Device 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
| Route | Body | Answer |
|---|---|---|
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
| Route | Body | What 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/whoami | none | Whether 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} | none | The approval's status, or 404 APPROVAL_NOT_FOUND |
POST /api/cli/projects/create | a provisioning grant | Creates the project the grant names |
POST /api/cli/workspaces/create | a workspace provisioning grant | Creates the workspace the grant names |
Access tokens
Session only, all three. See what a token never reaches.
| Route | What it does |
|---|---|
GET /api/projects/{id}/access-tokens | List tokens |
POST /api/projects/{id}/access-tokens | Mint 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
| Route | Body or query | What it does |
|---|---|---|
GET /api/projects/{id}/builds | List 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 it | The 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}/logs | Build logs | |
GET /api/projects/{id}/builds/{buildId}/artifacts | Build artifacts, launchers included | |
GET /api/projects/{id}/builds/{buildId}/usage | The 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:
runCorrelation | Meaning |
|---|---|
run-name | The run carries this deploymentId in its name. runId and runUrl are this rebuild's run |
window | The build repository predates the deployment id, and exactly one run started within five seconds of dispatchedAt |
pending | No run carried the id yet. Read GET /builds/{buildId} and match its deploymentId, or a startedAt after dispatchedAt |
ambiguous | More than one run matched, so none is named |
unsupported | The build repository predates the deployment id and no single run matched |
Releases
| Route | What it does |
|---|---|
GET /api/projects/{id}/releases | List releases |
POST /api/projects/{id}/releases | Create 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}/events | The channel's promotion events, newest first. Rollback resolves its target from this log |
GET /api/projects/{id}/releases/promotion-runs | The promote runs behind the reported status. Owner only |
POST /api/projects/{id}/releases/cut | Cut a release. Owner only. Opens the version-bump PR in the source repository |
GET /api/projects/{id}/releases/cut | What the cut would cost, as allowed, limit, used and dispatchDisabled. Read it before you cut |
GET /api/projects/{id}/releases/promote-ready | The 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}/rollback | Roll 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.
| Value | Meaning | rollout | health |
|---|---|---|---|
live | Every browser's promote run concluded success | 100% | good |
promoting | A run is still going | 0% | monitoring |
failed | A run concluded anything else | 0% | degraded |
unconfirmed | No run answers for that pointer | 0% | 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
| Route | What it does |
|---|---|
GET /api/projects/{id}/stores/config | Store configuration |
PUT or PATCH /api/projects/{id}/stores/config | Update 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/status | Submission status per store |
GET /api/projects/{id}/stores/{store}/submissions | Submission history |
POST /api/projects/{id}/stores/{store}/submissions | Start 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/health | Queue a credential check on the build repository. Session only, owner only, counts as one dispatch |
POST /api/projects/{id}/stores/chrome/rollout | Raise 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}/cancel | Pull 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
| Route | Body | Answer |
|---|---|---|
GET /api/projects/{id}/visibility | visibility, 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
| Route | Status | Code | Meaning |
|---|---|---|---|
POST /api/cli/stores/submit | 503, Retry-After: 10 | The platform cannot take the submission. Nothing was dispatched and nothing was recorded. Retry the same call unchanged | |
POST /api/cli/stores/submit | 404 | UNKNOWN_BUILD | The sha is not in the build index. Push, or dispatch a build for that commit, before submitting it |
POST /api/cli/stores/submit | 409 | SAFE_MODE_INPUT_UNSUPPORTED | The store workflow predates the safe default. Save the project settings to resync it |
POST /api/cli/stores/submit | 429 | BUILD_DISPATCH_DISABLED | The operator paused dispatch. Try again later |
POST /api/cli/stores/submit, POST /api/projects/{id}/stores/safari/submissions | 403 | MACOS_RUNNER_NOT_INCLUDED | A Safari submission on a free workspace. Nothing is dispatched and no allowance is spent |
| The same two | 403 | SAFARI_SUBMISSIONS_EXHAUSTED | The sixteenth App Store submission of the month on a paid workspace |
| The same two | 503 | SAFARI_ALLOWANCE_UNAVAILABLE | The Safari counter could not be read |
POST /api/projects/{id}/releases | 200 | MACOS_NOTARIZATION_NOT_INCLUDED under notarization.refused | Signing 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.
| Field | Meaning |
|---|---|
store | The store this verdict is about |
configured | The store has credentials, or unknown when no health was published |
healthy | The credentials pass the health probe, or unknown |
ok | The store would accept this submission |
reason | Why a blocked store is blocked |
consoleUrl | The console page that fixes that store |
checkedAt | When the health probe last ran |
A dry run that names Firefox also carries a FIREFOX_DATA_COLLECTION_PERMISSIONS warning. See Firefox.
When it fails
401with acodeon a token call. Read the code in token refusals.BAD_TOKENandTOKEN_NOT_TRACKEDmean mint a new token.REGISTRY_TOKENmeans you sent the OIDC exchange's credential.401with nocodeon 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 atPOST /api/cli/approvalsfrom theapprovalRequestin the answer, have a person approve it, and repeat the call with itsapprovalId.400 INVALID_ORIGIN. Theoriginis not one of the seven values or five older spellings. Useapifrom a pipeline.400 Missing buildSha. The body was not read as JSON. SendContent-Type: application/json.404 UNKNOWN_BUILD. Dispatch or push a build for that commit, then submit it.
Next
- Access tokens to mint the credential this API takes
- CI and releases for what a build costs and what the platform writes back
- Publishing for the submission states and dry runs