Errors

Search Documentation

Search across all developer documentation

Errors

The API returns errors as RFC 7807 problem documents with the content type application/problem+json. Every error response has the same shape, every status code maps to a type URI you can switch on programmatically, and every problem body is safe to log verbatim — no secrets are ever included.

The problem document shape

{
"type": "https://docs.sendops.dev/api/errors/invalid_scope",
"title": "Insufficient scope",
"status": 403,
"code": "invalid_scope",
"detail": "API key lacks required scope \"api.reports.view\".",
"request_id": "req_01HX7QY3..."
}
FieldMeaning
typeA stable URI identifying the error class. Switch on this, not on title. Its final path segment is the code slug.
titleA short human-readable summary. Suitable for surfacing to operators, not end users.
statusThe HTTP status, duplicated for clients that lose it (e.g. through a proxy).
codeThe stable, machine-readable slug for the error class — present on every problem body, and the same slug that ends the type URI.
detailA longer human-readable explanation. Often contains the specific scope or value.
request_idThe opaque request ID. Always include this when contacting support.

Specific error classes may add extra fields (e.g. 429 includes retry_after).

Catalogue

401 Unauthorized — invalid_key / key_revoked / key_expired

The credential didn’t authenticate. Most failures — a missing or malformed Authorization header, an unknown key, or a revoked key — return invalid_key: the body never says why it failed (we don’t reveal whether the key existed), so read code, not detail, to branch. A rotated key used past its 24-hour grace window returns key_expired; key_revoked identifies a key you already know about that has been revoked. In every case, fix the credential and retry.

403 Forbidden — invalid_scope

The key authenticated but lacks the scope this endpoint requires. The detail field (and the scope extension) names the missing scope. Edit the key’s scopes in the dashboard.

403 Forbidden — plan_retention_exceeded

The requested query window reaches further back than your plan’s retention. The retention_days extension carries the ceiling — clamp the from/range parameter to it (or upgrade the plan) and retry.

404 Not Found — not_found

The path is well-formed but the resource doesn’t exist (or doesn’t exist for this org — we return 404 instead of 403 on cross-org lookups to avoid leaking existence).

404 Not Found — contact_list_not_synced

A contacts-specific variant of 404 returned only by GET /v1/contact-list when the org has no contact list yet. It is distinct from not_found so you can tell “this org has never synced a contact list” apart from a genuine miss. The list appears automatically once AWS is connected and a sync completes — no client action is required, so poll rather than treat it as a hard error.

{
"type": "https://docs.sendops.dev/api/errors/contact_list_not_synced",
"title": "Contact list not synced",
"status": 404,
"code": "contact_list_not_synced",
"detail": "This org has no contact list yet. Connect AWS and run a sync, or create a contact list in Amazon SES.",
"request_id": "req_..."
}

409 Conflict — conflict

A write was rejected by a state rule rather than a bad request — a create-only POST where the resource already exists, an attempt to mutate a git-backed (read-only) definition, or an Idempotency-Key reused with a different body. Resolve the conflicting state (or pick a fresh key) and retry.

422 Unprocessable Entity — validation_failed

The request was well-formed but a value failed business validation — e.g. a cursor that decodes to an out-of-range offset. Fix the value and retry.

429 Too Many Requests — rate_limited

You’ve exceeded the per-org rate limit. Honour Retry-After. See Rate Limiting.

{
"type": "https://docs.sendops.dev/api/errors/rate_limited",
"title": "Rate limit exceeded",
"status": 429,
"code": "rate_limited",
"detail": "Too many requests. Retry after the period shown in Retry-After.",
"retry_after": 12,
"request_id": "req_..."
}

500 Internal Server Error — internal_error

Something went wrong on our side. The response will include a request_id — include it when you contact support. Retry with backoff — most 500s are transient (a transient downstream stutter, a brief connection blip). If you see a sustained 500 rate, treat the API as down.

What clients should do

A robust client implements the following matrix:

StatusRetry?Strategy
4xx (except 429)NoFix the request and try once more.
429YesSleep at least Retry-After seconds. Add jitter.
500Yes (transient)Exponential backoff (e.g. 250ms, 500ms, 1s, 2s).

A safe retry budget is 3–4 attempts with exponential backoff + jitter, capped at ~30 seconds total wait. Beyond that, surface the failure to the caller — the API is genuinely down and retrying won’t help.

Idempotency

Read endpoints (GET) are naturally idempotent — retrying is always safe. Write endpoints — contact writes (POST/PUT/PATCH/DELETE on /v1/contacts, and POST /v1/contacts/bulk) and broadcast create/send (POST /v1/broadcasts, POST /v1/broadcasts/{id}/send) — accept an optional Idempotency-Key header: send the same key on a retry and the original response is replayed for 24 hours, so a network failure mid-write never creates a duplicate (or fires a send twice). See Contacts & Topics → Idempotent writes.

Logging errors

Log the entire problem document plus the X-Request-ID response header. Don’t strip code or detail — they’re the most useful fields when debugging later. Don’t log the API key.

A minimal log entry looks like:

{
"level": "error",
"msg": "sendops api call failed",
"status": 403,
"code": "invalid_scope",
"type": "https://docs.sendops.dev/api/errors/invalid_scope",
"detail": "API key lacks required scope \"api.reports.view\".",
"path": "/v1/reports/deliverability",
"request_id": "req_01HX7QY3..."
}

Programmatic dispatch

Switch on the type URI (or, equivalently, the code slug it ends with), not status and not title. Both are stable across every language port of your client, so branching on them survives copy-tweaks and localization.

async function handle(res) {
if (res.ok) return res.json()
const problem = await res.json()
switch (problem.type) {
  case "https://docs.sendops.dev/api/errors/invalid_scope":
    throw new MissingScopeError(problem.detail)
  case "https://docs.sendops.dev/api/errors/rate_limited":
    throw new RateLimited(problem.retry_after)
  case "https://docs.sendops.dev/api/errors/not_found":
    return null
  default:
    throw new SendOpsError(problem)
}
}