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://api.sendops.dev/errors/permission_denied",
"title": "Permission denied",
"status": 403,
"detail": "The API key does not have the required scope: api.reports.view",
"instance": "/v1/reports/deliverability",
"request_id": "req_01HX7QY3..."
} | Field | Meaning |
|---|---|
type | A stable URI identifying the error class. Switch on this, not on title. |
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). |
detail | A longer human-readable explanation. Often contains the specific scope or value. |
instance | The request path that produced the error. |
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
400 Bad Request — invalid_request
The request was syntactically malformed: invalid JSON, missing required query parameter, bad enum value. Fix the request shape and retry.
{
"type": "https://api.sendops.dev/errors/invalid_request",
"title": "Invalid request",
"status": 400,
"detail": "query parameter 'range' must be one of: 24h, 7d, 30d",
"instance": "/v1/reports/deliverability",
"request_id": "req_..."
} 401 Unauthorized — unauthenticated
Authorization header missing, malformed, the key is revoked, or you used a key that doesn’t exist. The body never says why the auth failed (we don’t reveal whether the key existed) — fix the credential and retry.
403 Forbidden — permission_denied
The key is valid but lacks the scope this endpoint requires. The detail field names the missing scope. Edit the key’s scopes in the dashboard.
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).
409 Conflict — conflict
A precondition for the request was not met. Reserved for future write endpoints; v1 read endpoints rarely emit this.
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_limit
You’ve exceeded the per-org rate limit. Honour Retry-After. See Rate Limiting.
{
"type": "https://api.sendops.dev/errors/rate_limit",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Too many requests for this organization. Retry in 12 seconds.",
"retry_after": 12,
"instance": "/v1/messages",
"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.
503 Service Unavailable — service_unavailable
The API is temporarily degraded — a downstream dependency is briefly unavailable. The response carries Retry-After. Back off and retry.
What clients should do
A robust client implements the following matrix:
| Status | Retry? | Strategy |
|---|---|---|
| 4xx (except 429) | No | Fix the request 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 | Yes | Sleep Retry-After; treat sustained as outage. |
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
Every v1 endpoint is read-only and therefore idempotent — retrying is always safe. When write endpoints arrive in a later phase, they will require an explicit Idempotency-Key header for safe retries.
Logging errors
Log the entire problem document plus the X-Request-ID response header. Don’t strip detail or instance — 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,
"type": "https://api.sendops.dev/errors/permission_denied",
"detail": "The API key does not have the required scope: api.reports.view",
"path": "/v1/reports/deliverability",
"request_id": "req_01HX7QY3..."
} Programmatic dispatch
Switch on the type URI, not status and not title. type is stable across language ports of your client and tracks our internal taxonomy directly.
async function handle(res) {
if (res.ok) return res.json()
const problem = await res.json()
switch (problem.type) {
case "https://api.sendops.dev/errors/permission_denied":
throw new MissingScopeError(problem.detail)
case "https://api.sendops.dev/errors/rate_limit":
throw new RateLimited(problem.retry_after)
case "https://api.sendops.dev/errors/not_found":
return null
default:
throw new SendOpsError(problem)
}
}