API keys & scopes
Why scopes exist
Before scopes, every API key was full-power. A key an agency handed to a reporting dashboard so it could read usage could equally run image operations — spending the merchant's AI budget with a credential that was never meant to write anything.
There was no way to mint a restricted key, so least privilege was not a policy anyone could actually follow.
The scope catalogue
| Scope | Mutating | Grants |
|---|---|---|
usage:read | No | Read the shop's operation counts, quota and billing-period usage |
images:read | No | Read image operation metadata and results |
images:write | Yes | Run background removal, upscaling and lifestyle scene generation. Consumes AI credits against the shop's monthly budget |
* | — | Everything. Kept out of the catalogue so it is never a one-click checkbox |
images:write implies images:read. An integrator allowed to run an upscale is
obviously allowed to read its result.
The mutating flag is not decoration — it drives the "this key can spend money"
warning when you mint, and it is what makes a read-only key the obvious default.
Implication is resolved at grant time
When you mint a key with images:write, the stored scopes are
["images:read", "images:write"]. Runtime checking is exact match.
"Write implies read" as a check-time rule produces two scope algebras — one here, something cleverer somewhere else — and the moment they drift, one surface honours a grant the other refuses.
Resolving once at mint means the stored row is a complete, readable statement of what the key can do, without re-running any rules. That is exactly what you want in front of you during an incident.
Expansion also de-duplicates, returns scopes in catalogue order so two equivalent
grants serialise identically, and collapses to ["*"] when the wildcard is
present — because a wildcard alongside a list reads as a restriction that is not
one.
Unknown scopes are rejected
An unrecognised scope code is refused when the key is minted. Storing it would produce a key that appears to grant something and grants nothing; the failure surfaces as a production 403 long after whoever made the typo has moved on.
Three states, three meanings
| Stored value | Meaning |
|---|---|
| No scopes recorded | A key minted before scopes existed. Unrestricted, exactly as it was |
[] | A key deliberately granted nothing. Inert by design |
["usage:read"] | Exactly what it says |
The first two are deliberately distinguishable. Backfilling legacy keys to ["*"]
would be equivalent in effect but would erase the one signal that shows which
integrations still need migrating.
Existing keys continue to work unchanged across the release that introduced scopes. Default-denying them would have broken every merchant's production integration the moment the release landed.
If you operate an integration on a pre-scopes key, mint a scoped replacement and retire the old one.
Minting a key
Keys are minted from the app's settings. The mint form:
- Defaults to read-only scopes
- Warns on any mutating scope
- Shows the key once — only a hash is stored
Using a key
curl -X GET \
'https://<your-app-host>/api/v1/usage' \
-H 'X-API-Key: <your-key>'
A request whose key lacks the required scope returns 403, with the missing
scope named in the response so the fix is obvious.
Relationship to internal tokens
The scope primitive is shared with the internal token system: both credential types resolve grants through one implementation, so the algebra cannot diverge between them. What differs is the catalogue — this page documents the public API's.
Plan tier
The public API and its keys are available on Agency and Enterprise plans. See Billing & plans for what each tier includes.
See also
- API overview — the public API surface
- v1 Admin API — the programmatic admin surface
- Rate limits — quotas and throttling
- SSO & SCIM — directory-driven access for people rather than integrations