Skip to main content

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

ScopeMutatingGrants
usage:readNoRead the shop's operation counts, quota and billing-period usage
images:readNoRead image operation metadata and results
images:writeYesRun 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.

Why not resolve implication at check time?

"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 valueMeaning
No scopes recordedA 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.

Legacy keys keep working

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