# HTTP API

> The platform API contract for machines.

Canonical: https://docs.extension.dev/automate/api
This page as Markdown: https://docs.extension.dev/automate/api.md
Section: Automate
Siblings: [CI and releases](https://docs.extension.dev/automate/ci.md)
Map of every page: https://docs.extension.dev/llms.txt · Whole corpus: https://docs.extension.dev/llms-full.txt

---

# 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](/tools/access-tokens).


## How it works

### Two credentials

A [project token](/tools/access-tokens) 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](#what-a-token-never-reaches) 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](/automate/ci#why-a-build-started).

What bounds the API is the monthly build allowance and the burst window, see
[extension.dev/pricing](https://www.extension.dev/pricing). The one plan gate
is Safari, which needs a paid plan, named in the
[error contract](#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.

::: warning 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](#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](/tools/access-tokens#revocation) |

### 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](/publish/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](/publish/overview#watching).

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](/builds/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](/publish/firefox).

## When it fails

- **`401` with a `code` on a token call.** Read the code in
  [token refusals](#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

- [Access tokens](/tools/access-tokens) to mint the credential this API takes
- [CI and releases](/automate/ci) for what a build costs and what the
  platform writes back
- [Publishing](/publish/overview) for the submission states and dry runs
