Skip to main content
Reference for the SideShift OAuth API (/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 on message.
  • message - a human-readable explanation. Wording may change; it is for logs and humans, not control flow.
  • requestId - a req_… 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.
The HTTP status line always matches 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: no Authorization header, 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 hold campaigns:read and called POST /campaigns, which needs campaigns:write). The fix is to re-authorize, requesting the missing scope on the consent screen. The response carries a WWW-Authenticate header 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).
    Re-authorizing will not fix a 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 a 401 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.
A plain 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:
Common 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. 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

Honor Retry-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.
Always send an Idempotency-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

Reuse the same 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 a meta.reason you can branch on. Nothing is sent when a request is refused. Example:

See also: OAuth quickstart · workflows.md · openapi.yaml.