> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sideshift.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors, rate limits, and idempotency

> The full error code registry, rate-limit headers, and idempotency rules for the Platform API.

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](/quickstart) (register,
authorize, get a token) and [workflows.md](/platform/workflows) (end-to-end recipes).
The machine-readable contract is [openapi.yaml](/platform).

***

## 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:

```json theme={"system"}
{
  "error": {
    "code": "insufficient_scope",
    "message": "Requires scope 'campaigns:write'",
    "requestId": "req_8f1c2e9a4b7d"
  }
}
```

* `code` - a **stable** machine-readable code from the [registry](#2-error-code-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`).

| Code                    | HTTP | Meaning                                                                                                |
| ----------------------- | ---- | ------------------------------------------------------------------------------------------------------ |
| `invalid_request`       | 400  | Malformed request or failed validation (bad body, missing field, bad query param).                     |
| `unauthorized`          | 401  | Bearer access token is missing, expired, or revoked. Re-authenticate.                                  |
| `subscription_required` | 402  | The token's company tenant does not have an active subscription.                                       |
| `insufficient_scope`    | 403  | Valid token, but it does **not** carry the scope this operation requires. Re-authorize with the scope. |
| `forbidden`             | 403  | The action is **never** permitted for this grant, regardless of scope - see below.                     |
| `not_found`             | 404  | The resource does not exist **or belongs to another tenant** (see below).                              |
| `conflict`              | 409  | A state conflict (e.g. voiding an already-paid invoice).                                               |
| `idempotency_conflict`  | 409  | An `Idempotency-Key` was reused with a **different** request body. See [§6](#6-idempotency).           |
| `rate_limited`          | 429  | Too many requests for this (client, company). See [§5](#5-rate-limits).                                |
| `internal`              | 500  | An unexpected server error. Safe to retry idempotent requests with backoff.                            |

### `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`](#3-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`

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 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:

```http theme={"system"}
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="campaigns:write", resource_metadata="https://app.sideshift.app/.well-known/oauth-protected-resource"
Content-Type: application/json
```

* `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](/platform) 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:

```json theme={"system"}
{ "error": "invalid_grant", "error_description": "Authorization code is invalid or expired" }
```

Common `error` values:

| `error`                   | Where / HTTP | Meaning                                                                                                                                                                  |
| ------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `invalid_request`         | 400          | Missing/duplicated/malformed parameter.                                                                                                                                  |
| `invalid_client`          | 401          | Client authentication failed (token/revoke) or the registration access token is invalid (`/clients/{id}`). Carries a `WWW-Authenticate` (`Basic` or `Bearer`) challenge. |
| `invalid_grant`           | 400          | Authorization code or refresh token is invalid, expired, revoked, or reused.                                                                                             |
| `unauthorized_client`     | 400          | The client may not use this grant type.                                                                                                                                  |
| `unsupported_grant_type`  | 400          | `grant_type` is not one we support.                                                                                                                                      |
| `invalid_scope`           | 400          | Requested scope is unknown or not allowed for the client.                                                                                                                |
| `invalid_redirect_uri`    | 400          | `redirect_uri` is missing or does not exactly match a registered URI.                                                                                                    |
| `invalid_client_metadata` | 400          | A field in a `/register` (or `/clients/{id}` update) request is invalid.                                                                                                 |
| `temporarily_unavailable` | 429          | Rate-limited (per-IP or per-client). See [§5](#5-rate-limits).                                                                                                           |

`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`.

| Surface / endpoint                            | Limit         | Scope (subject)                     |
| --------------------------------------------- | ------------- | ----------------------------------- |
| Resource endpoints (all of `/api/oauth/v1/*`) | **600 / min** | per **(client, company)**           |
| `POST /register`                              | 20 / min      | per IP                              |
| `GET /authorize`                              | 60 / min      | per IP                              |
| `POST /token`                                 | 120 / min     | per **client**                      |
| `POST /token` (unauthenticated)               | 60 / min      | per IP (when no/failed client auth) |
| `POST /revoke`                                | 120 / min     | per **client**                      |

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:

| Header                  | Value                                             |
| ----------------------- | ------------------------------------------------- |
| `X-RateLimit-Limit`     | Max requests allowed in the window.               |
| `X-RateLimit-Remaining` | Requests remaining in the window (never below 0). |
| `X-RateLimit-Reset`     | Window reset time, **epoch seconds**.             |

A `429` response (`rate_limited` envelope, or `temporarily_unavailable` on the
protocol surface) **additionally** carries:

| Header        | Value                            |
| ------------- | -------------------------------- |
| `Retry-After` | Seconds to wait before retrying. |

### 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.

### Strongly recommended for money-moving calls

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](#2-error-code-registry)) - test-mode
tokens cannot move real money or fire real external effects.

### Example

```bash theme={"system"}
curl -sS -X POST https://app.sideshift.app/api/oauth/v1/payouts/quick-pay \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "email": "creator@example.com", "amount": 100, "notes": "Thanks!" }'
```

Reuse the *same* `Idempotency-Key` value when retrying that exact request after a
timeout; generate a new one for a genuinely new payment.

***

See also: [OAuth quickstart](/quickstart) ·
[workflows.md](/platform/workflows) · [openapi.yaml](/platform).
