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..."
} | Field | Meaning |
|---|---|
type | A stable URI identifying the error class. Switch on this, not on title. Its final path segment is the code slug. |
title | A short human-readable summary. Suitable for surfacing to operators, not end users. |
status | The HTTP status, duplicated for clients that lose it (e.g. through a proxy). |
code | The stable, machine-readable slug for the error class — present on every problem body, and the same slug that ends the type URI. |
detail | A longer human-readable explanation. Often contains the specific scope or value. |
request_id | The 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.
422 Unprocessable Entity — attribute_validation_failed
A contact write carried custom attributes that don’t match your org’s attribute registry — an unknown key, or a value that can’t be coerced to the registered type. Distinct from validation_failed so you can tell a schema problem apart from a bad parameter. The body carries a per-attribute errors[] array naming each offending key. Fix the values (or register the attribute) and retry. See Contacts & Topics → attributes.
In bulk import the same failure doesn’t reject the request: it marks that one row failed with a reason, and the rest of the batch proceeds.
429 Too Many Requests — rate_limited
You’ve exceeded the per-org rate limit. Honour Retry-After. See Rate Limiting.
This is the only 429 these endpoints return, and it’s ours — a plan limit, with the X-RateLimit-* headers described in Rate Limiting. Throttling inside the AWS account connected to your org is absorbed by SendOps and never reaches you as a 429.
{
"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_..."
} 503 / 504 — upstream_unavailable
An upstream dependency — the AWS SES account connected to your org — refused, throttled, or didn’t answer in time. The status is 503 when the call failed and 504 when it exceeded our deadline.
Retryable. Always accompanied by Retry-After and a matching retry_after body member; back off for the stated interval rather than retrying immediately. On a 504 the outcome is genuinely unknown — the write may have reached SES — so a retry of a non-idempotent call should carry an Idempotency-Key.
The detail string is deliberately generic. The underlying AWS error describes your own account, so it’s recorded in SendOps’ logs against the request_id rather than returned here — quote that id to support.
This appears on the endpoints that still call SES synchronously: topic reads and writes, contact reads, PUT /v1/contacts/by-external-id/{external_id}, and the workflow dry-run. It does not appear on POST /v1/contacts/bulk, which is accepted locally and mirrored in the background.
Existing integrations: this used to be a 422
Contact and topic writes previously returned 422 validation_failed when the connected AWS SES account throttled or timed out — a terminal-looking status for a condition that was actually transient. Those cases now return 503/504 upstream_unavailable.
If your client treats any 422 on these endpoints as “bad payload, do not retry”, update it: the retryable cases have moved out of 422 and into the new statuses, and a 422 is now genuinely terminal.
503 Service Unavailable — resource_preparing
A resource SendOps is still building in the background isn’t ready yet. Retryable, and carries Retry-After — poll rather than treating it as a failure.
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. Branch on code, not on the status alone — 503 covers two different conditions.
| Status | Retry? | Strategy |
|---|---|---|
| 4xx (except 429) | No | Terminal. Retrying changes nothing until the request — or the connected AWS account — changes. Fix and try once more. |
| 429 | Yes | Sleep at least Retry-After seconds. Add jitter. |
| 500 | Yes (transient) | Exponential backoff (e.g. 250ms, 500ms, 1s, 2s). |
| 503 / 504 | Yes | Honour Retry-After. On 504 the write may have landed — retry non-idempotent calls with an Idempotency-Key. |
The retryable statuses are therefore 429, 503, 504 and 500; every other 4xx is terminal.
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)
}
}