# Error codes

> Every error code returned by the Partner API and how to handle it.

Source: https://sndr.sh/docs/cranl/errors

All errors use a consistent JSON envelope (the only exception is the per-IP rate limit, which returns a plain-text 429):

```json
{
  "error": {
    "code":       "invalid_grant",
    "message":    "Authorization code has already been used",
    "request_id": "req_01HXKQ8B3N",
    "docs_url":   "https://docs.sndr.sh/errors/invalid_grant"
  }
}
```

Include request_id in any support request — it links the failure to server-side logs. Validation errors also carry field (one input) or fields (a map of input to message).

## OAuth errors

- invalid_client (401) — Unknown or inactive client_id, or wrong client_secret.
- invalid_grant (400) — Auth code expired (10 min TTL), already used, PKCE mismatch, or refresh token invalid.
- invalid_scope (400) — Scope not in the app's scopes_allowed list.
- access_denied — User clicked Deny (redirect parameter, not a JSON body). Restart the flow if the user changes their mind.
- unsupported_grant_type (400) — Only authorization_code and refresh_token are accepted.
- invalid_request (400) — Missing required parameter or malformed body.

## Resource errors

- unauthenticated (401) — Missing Authorization header, or the Bearer token is unknown.
- token_expired (401) — Access token passed its 1-hour TTL. Exchange the refresh token.
- token_revoked (401) — Token was explicitly revoked or the user disconnected the integration.
- insufficient_scope (403) — Token lacks the required scope (read or send).
- not_found (404) — Resource does not exist or belongs to a different account.
- invalid_request (400) — Body is not valid JSON, has an unknown field or a wrong type, or a send request failed validation. The message gives the detail.
- validation_failed (422) — A field failed validation; field or fields names the input. Also returned for a bad limit or cursor.
- conflict (409) — Duplicate resource (e.g. contact email or domain already exists) or an invalid signing-key transition.
- name_taken (409) — A contact group with the same name already exists.
- chunk_too_large (400) — More than 500 contacts in one bulk import.
- suppressed (400) — Every to address of a send is on the suppression list.
- domain_not_linked (403) / domain_not_verified (403) — The from domain is not added, or not verified yet.
- quarantine_active (403) — Sending is paused for this account. Contact SNDR support.
- not_editable (400) — Tried to edit a broadcast that is no longer a draft.
- cannot_queue (409) — Tried to send a broadcast that is not a draft.
- empty_audience (400) — The broadcast’s group has no subscribed contacts.
- plan_limit_reached (402) — A plan cap (domains, templates, webhooks) is reached. Send the user to upgrade_url from GET /me.
- quota_exceeded (402) — The monthly email quota is used up. It resets on the 1st of the month (UTC).
- payload_too_large (413) — A send request is over 16 MiB.
- send_velocity (429) — Sending too fast for this account. Back off and retry.
- 429 with a plain-text body — Per-IP rate limit. Wait for the Retry-After header.
- sends_frozen (503) — Sending is temporarily paused platform-wide. Retry later.
- server_error / internal (500) — Internal error. Retry with exponential backoff and quote request_id.

## Retry strategy

- 401 token_expired — Refresh once, then retry the original request.
- 429 (rate limit or send_velocity) — Wait for the Retry-After duration (or back off), then retry.
- 402 quota_exceeded / plan_limit_reached — Do not retry. The email quota resets on the 1st (UTC); plan caps need an upgrade.
- 500 / 503 — Retry with exponential backoff: 1s → 2s → 4s → 8s → stop after 5 attempts. Send an Idempotency-Key on POST /send so a retry never sends twice.
- All other 4xx — Fix the request; do not retry.

> **Note:** POST /api/cranl/oauth/revoke always returns 200 OK per RFC 7009 — even for unknown or already-revoked tokens.
