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://developers.sendops.dev/api-reference/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).

The type host changed in October 2026

Until October 2026, type URIs began https://docs.sendops.dev/api/errors/, a host that never resolved. They now begin https://developers.sendops.dev/api-reference/errors/, and each one opens its entry below. The final path segment, and the code member, are unchanged. If you match on the full type string, update the prefix, or match on code.

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. In every case, fix the credential and retry.

key_revoked is never returned as a response. It appears only on the Send API, as the error.code of a queued send whose key was revoked, or expired past its grace window, before the send went out.

402 Payment Required — plan_limit_exceeded

The action would take your organization past a limit of its plan, or uses a feature the plan does not include. limit_type names the limit, current and limit say where you stand, and upgrade_url is the dashboard page that lifts it. On monthly Business, overage_available: true means switching overage on lifts the cap without changing plan. Terminal: retrying succeeds only once the plan or the usage changes. The full field list and every limit_type are in Plans & limits.

{
"type": "https://developers.sendops.dev/api-reference/errors/plan_limit_exceeded",
"title": "Plan limit exceeded",
"status": 402,
"code": "plan_limit_exceeded",
"detail": "plan limit reached: segments (3/3 on free plan)",
"limit_type": "segments",
"current": 3,
"limit": 3,
"plan_tier": "free",
"upgrade_tier": "team",
"upgrade_url": "https://app.sendops.dev/acme/billing?reason=segments&upgrade=team",
"overage_available": false,
"purchase_available": false,
"request_id": "req_..."
}

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 no contact list” apart from a genuine miss. Adding the org’s first contact or creating its first topic creates the list. A sync also picks up a list that already exists in the AWS account’s SES. So don’t poll for it: if the org has neither contacts nor topics, the list does not exist yet.

{
"type": "https://developers.sendops.dev/api-reference/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. Adding a contact or creating a topic creates one; so does a sync of an AWS account that already has one.",
"request_id": "req_..."
}

409 Conflict — conflict

A write was rejected by a state rule rather than by a bad request. The body says which; there are four families, and the remedy differs:

CauseWhere you meet itRemedy
The name is takenPOST /v1/lists, /v1/segments, /v1/workflows (a key already in use), POST /v1/topics (a name), POST /v1/contacts (an active contact with that email), POST /v1/inbound/domainsPick another key, or address the existing row with a PUT.
A stale expected_content_hashPUT /v1/templates/{slug}, PUT /v1/segments/{id}, PUT /v1/workflows/{id}Someone edited the row since you read it. The response carries the row’s current content_hash, so the retry is one call: re-read, merge, send the new hash.
Something still depends on itDELETE /v1/templates/{slug}, /v1/segments/{id}, /v1/assets/{id}The response names the dependents in referenced_by as {kind, key, name} — an unarchived workflow that sends the template or enrolls on the Segment, a draft or scheduled broadcast that pins it, a live template that references the image path. Repoint or remove the dependent, then retry.
The row is in the wrong state for the verbPOST /v1/workflows/{id}/activate (not draft or paused), POST /v1/workflows/{id}/pause (not active), POST /v1/workflows/{id}/go-live (not in shadow), POST /v1/segments/{id}/evaluate (paused, invalid or archived)Move the row to the state the verb expects first.
An API key request in the wrong statePOST /v1/api-key-requests/{id}/claim (still pending, denied, already claimed, lapsed, or the requesting credential was revoked)GET the request for its status; if it lapsed, make a new request. See API key requests.

An Idempotency-Key reused with a different body is also a 409; pick a fresh key. Two 409s carry their own code rather than the generic one: edge_cdn_not_provisioned on POST /v1/assets when the org has no Edge CDN to host the bytes, and attribute_exists_differs on PUT /v1/attributes/by-name/{name} when the name exists with a different schema (that route never edits — it leaves the stored definition alone).

A 409 is never about where a definition came from. Every definition in an org is writable by a caller holding the matching .manage scope.

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.

On the Send API (early access) the same code comes with status 400, not 422. See Send API errors.

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.

On the Public API this 429 is always ours — a plan limit, with the X-RateLimit-* headers described in Rate Limiting. One endpoint adds limits of its own under the same code: POST /v1/api-key-requests returns 429 when the credential has made 10 requests in the past hour (with Retry-After) or when the pending-request caps are full (without one); detail names the limit. See API key requests. Throttling inside the AWS account connected to your org is absorbed by SendOps and never reaches you as a 429.

{
"type": "https://developers.sendops.dev/api-reference/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.

Send API errors

The Send API on ingest.sendops.dev is in early access. It uses the same problem envelope and type URIs, and the 401, 403 invalid_scope, 404, 429 rate_limited, 500 and 503 upstream_unavailable entries above apply to it too. It differs in these ways:

  • validation_failed is a 400, not a 422. field names the input at fault as a JSON path (to[2], attachments[0].content), or Content-Type for an unsupported body format. A definite refusal by your SES account (for example MessageRejected) is also a 400 validation_failed, carrying SES’s reason.
  • 503 upstream_unavailable also covers your SES account throttling or running out of daily quota, and SendOps itself being briefly unreachable. Retry after Retry-After with the same Idempotency-Key.
  • An organization without early access gets 404 not_found on every Send API path.

It adds these codes:

403 Forbidden — test_environment_forbidden

An sk_test_ key named a recipient that is neither an SES mailbox-simulator address (…@simulator.amazonses.com) nor on one of your organization’s own verified, managed identities. Nothing was sent. Terminal: change the recipient or use a live key.

403 Forbidden — edge_required

The request did not come through ingest.sendops.dev. You won’t see it when you call that host; it exists so that nothing else can reach the Send API.

405 Method Not Allowed — method_not_allowed

The path exists but not with that method, for example GET /v1/send. Terminal.

409 Conflict — idempotency_key_conflict

The Idempotency-Key was already used for a different body, or a request under the same key is still being sent. In the second case the problem carries Retry-After: retry with the same key after it to get the outcome. In the first, use a new key for a new message. See Idempotency.

413 Content Too Large — payload_too_large

The request body is over 40 MB (41,943,040 bytes), measured as received, so base64-encoded attachments count at their encoded size. Terminal: shrink the body.

429 Too Many Requests — send_quota_exceeded

Your organization has used its plan’s monthly fair-use number of live API sends. The body carries limit, used and resets_at (when the billing period resets), and Retry-After is the seconds until then. Nothing was sent and the Idempotency-Key stays unused. Don’t retry on a timer; send again after resets_at. See Monthly fair-use limit.

503 Service Unavailable — overloaded

SendOps is shedding load. Always comes with X-SendOps-Shed: 1 and Retry-After. Retryable with the same Idempotency-Key. Most requests that meet a shed are queued and answered 202 instead, so you see this only when a request could not be queued.

What clients should do

A robust client implements the following matrix. Branch on code, not on the status alone — 503 covers two different conditions.

StatusRetry?Strategy
4xx (except 429)NoTerminal. Retrying changes nothing until the request — or the connected AWS account — changes. Fix and try once more.
429YesSleep at least Retry-After seconds. Add jitter.
500Yes (transient)Exponential backoff (e.g. 250ms, 500ms, 1s, 2s).
503 / 504YesHonour 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. On the Send API, two exceptions: 429 send_quota_exceeded waits until resets_at, and a 409 idempotency_key_conflict that carries Retry-After is retried with the same key.

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://developers.sendops.dev/api-reference/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://developers.sendops.dev/api-reference/errors/invalid_scope":
    throw new MissingScopeError(problem.detail)
  case "https://developers.sendops.dev/api-reference/errors/rate_limited":
    throw new RateLimited(problem.retry_after)
  case "https://developers.sendops.dev/api-reference/errors/not_found":
    return null
  default:
    throw new SendOpsError(problem)
}
}