/api/oauth/v1): the error envelope, the
stable error-code registry, the WWW-Authenticate challenge, protocol-endpoint
errors, rate-limit policy + headers, and idempotency.
New to the API? Start with OAuth quickstart (register,
authorize, get a token) and workflows.md (end-to-end recipes).
The machine-readable contract is openapi.yaml.
1. Error envelope (resource endpoints)
Every error from a resource endpoint (campaigns, contracts, creators, applications, posts, payouts, invoices, messages, webhooks, settings, invites) is returned as a structured JSON envelope:code- a stable machine-readable code from the registry. Branch on this, never onmessage.message- a human-readable explanation. Wording may change; it is for logs and humans, not control flow.requestId- areq_…correlation id. Always include it when contacting support so we can find the exact request in our logs.meta- an optional object with structured context (e.g. the missing scope, the conflicting field). Present only when useful; treat it as additive.
code (see the table below).
2. Error code registry
These are the only codes a resource endpoint emits. Status is fixed per code (single source of truth:lib/api/core/errors.ts).
unauthorized vs insufficient_scope vs forbidden
These three are routinely confused. They are distinct:
-
unauthorized(401) - the credential itself is bad: noAuthorizationheader, an expired access token (1h TTL), or a revoked token. The fix is to re-authenticate (refresh the token, or restart the authorization flow). -
insufficient_scope(403) - the token is valid but was not granted the scope this call needs (e.g. you holdcampaigns:readand calledPOST /campaigns, which needscampaigns:write). The fix is to re-authorize, requesting the missing scope on the consent screen. The response carries aWWW-Authenticateheader naming the required scope. -
forbidden(403) - the action is never allowed for this grant, no matter what scope you add. This is a hard policy/capability block, e.g.:- a sandbox / test-mode grant attempting a money-moving or external side-effect write (payouts execute / Quick Pay, invoice create/send/void, sending a message); or
- a policy block that is provably not cross-tenant (e.g. invoicing not available for the account, account too new).
forbidden. Example body:"payouts:write is not available for sandbox (test-mode) grants".
Cross-tenant access is always not_found (404), never forbidden
A token is bound to exactly one company tenant. If you request a resource id
that belongs to another company, the API returns 404 not_found - the
same response as a genuinely nonexistent id. It is never 403. This is
deliberate: you cannot use the status code to probe whether another company’s
resource exists. 403 forbidden is reserved for the in-tenant policy/capability
blocks described above.
3. WWW-Authenticate
On a401 unauthorized and on a 403 insufficient_scope, the response
carries an RFC 6750 / RFC 9728 WWW-Authenticate: Bearer … challenge that tells
the client what to do:
error- the OAuth error (invalid_token/insufficient_scope).scope- the scope the client must request to perform this operation. Read it, add it to your next authorization request, and have the user re-consent.resource_metadata- the URL of the protected-resource metadata document (RFC 9728), which lists the authorization server and supported scopes for discovery.
403 forbidden (the hard policy block) does not carry a
WWW-Authenticate header - there is no scope that would grant access.
4. Protocol endpoint errors (RFC 6749)
The auth-server endpoints -/register, /authorize, /token, /revoke,
/clients/{id} - are not resource endpoints. They follow OAuth conventions and
return the RFC 6749 error body instead of the envelope:
error values:
subscription_required is a resource API error, not a protocol or token-endpoint error. A client
can finish consent and receive tokens while its company subscription is inactive, but protected
resource calls return 402 until the subscription is active.
/authorize is special: when the client_id/redirect_uri cannot be trusted it
renders an HTML error page (400) rather than redirecting; otherwise
recoverable errors come back as a redirect to redirect_uri carrying
error, error_description, state, and iss.
Consent endpoints (/consent)
The in-session consent bridge is not an RFC 6749 endpoint. It uses a small
{ "error", "message" } body, where error is one of:
invalid_request (400), unauthorized (401), subscription_required (402),
forbidden / csrf (403), not_found (404), already_used (409),
or expired (410).
5. Rate limits
Limits are per rolling 60-second window, scoped by subject. Source of truth:lib/api/oauth/rate-limit.ts.
Because the resource limit is per (client, company), one misbehaving client
cannot exhaust the budget of a tenant it shares with other clients.
Headers
Every response carries the current window state:
A
429 response (rate_limited envelope, or temporarily_unavailable on the
protocol surface) additionally carries:
Handling 429
HonorRetry-After: wait at least that many seconds, then retry with
exponential backoff and jitter for repeated failures. Proactively, watch
X-RateLimit-Remaining and throttle yourself before you hit zero rather than
hammering until you get a 429.
6. Idempotency
Every mutating resource endpoint (POST / PATCH / PUT) accepts an
Idempotency-Key request header. The key is client-chosen - use a fresh
UUID per logical operation.
Semantics:
- Replay (same key + same body) → the original stored response is returned (same status + body). Safe to retry after a network timeout without double-applying the operation.
- Same key + a different body →
409 idempotency_conflict. A key is bound to the first request body it saw. - Keys are retained for roughly 24 hours, then forgotten.
Strongly recommended for money-moving calls
Always send anIdempotency-Key on calls that move money or fire external side
effects - POST /payouts/execute, POST /payouts/quick-pay, and the invoice
writes (POST /invoices, /invoices/{id}/send, /invoices/{id}/void). For the
payout endpoints the key is required (a request without one is rejected
400); it is the sole double-pay guard for Quick Pay. The key is also required
by POST /campaigns/{id}/analytics-history so a retried multi-store import
cannot apply a second logical batch; that endpoint enforces an 8–200 character
key length.
Note these same endpoints are also rejected with 403 forbidden for
sandbox / test-mode grants (see §2) - test-mode
tokens cannot move real money or fire real external effects.
Example
Idempotency-Key value when retrying that exact request after a
timeout; generate a new one for a genuinely new payment.
See also: OAuth quickstart · workflows.md · openapi.yaml.