/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
Every request has one effective company tenant: the connected company by
default, or one consented direct child selected by an agency-parent OAuth grant
with X-Act-As-Company. If you request a resource id that belongs to any other
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.
subscription_required beyond lapsed subscriptions
402 subscription_required also fires when a write would exceed a plan limit.
The one integrator-visible case today: approving a campaign application
(POST /applications/{id}/status) when the company is at its creator seat cap.
The application stays pending and nothing is written. The message names the
usage and cap. Free a seat by ending an active contract, or upgrade the plan,
then retry the same call.
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.
The two messaging budgets sit on top of the resource limit and apply to every
messaging write route, whichever credential (OAuth client, Platform key) made
the call. A refused send or create answers
429 rate_limited with
meta.retryAfterSeconds and meta.budget (write or create); nothing was
sent.
Headers
Every response carries the current window state:
A
429 response (rate_limited envelope, or temporarily_unavailable on the
protocol surface) additionally carries:
A messaging-budget
429 carries the same number in the body as
meta.retryAfterSeconds, so a client that reads only the envelope still
knows how long to wait.
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.
7. Messaging refusals
Messaging routes use the registry codes above with ameta.reason you can
branch on. Nothing is sent when a request is refused.
Example:
See also: OAuth quickstart · workflows.md · openapi.yaml.