Skip to content
Docs menu

Errors

Every error the API can return, live or documented, uses one body shape and one of a fixed set of stable slugs. Branch on error, never on message, which may be reworded at any time.

The error body

A non-2xx response is always this shape:

Error response
{
  "error": "validation_error",
  "message": "body/to/0 Invalid email address",
  "details": [
    {
      "keyword": "invalid_format",
      "instancePath": "/to/0",
      "schemaPath": "#/to/0/invalid_format",
      "message": "Invalid email address",
      "params": {
        "origin": "string",
        "format": "email"
      }
    }
  ]
}

error is a stable, machine-readable slug: safe to branch your code on. message is for humans and may be reworded at any time; don't match on its text. details is present only on some errors (mainly validation failures). For a route whose body, params, or query are validated by an attached schema, essentially every route in this reference, it's the array shown above, produced by the validator itself: each entry carries keyword/instancePath/schemaPath/message/params, not code/path. Two routes validate their body by hand instead of through an attached schema and still return the older {code, path, message, ...} issue shape: PUT /api/settings and POST /v1/sandbox/messages/:id/simulate. And one check runs after schema validation has already passed, so it answers in its own shape: POST /v1/emails/send rejecting a reserved or malformed custom header returns {field, message} entries naming the offending fields. So branch on error first and treat details as advisory, reading a key that a given entry happens not to carry is the one way to get this wrong.

The error slugs

Every error the API returns, including a bare validation failure that never reaches a route handler, is mapped to exactly one of these. There are 16; the two pairs that share an HTTP status (quota_exceeded/rate_limited both 429, sandbox_unavailable/service_unavailable both 503) are deliberately kept as separate slugs rather than collapsed, because the correct client response to each half of a pair differs.

SlugStatusMeaningRetryable
validation_error400The request body, query string, or a path parameter failed validation.No. Fix the request first.
unauthorized401The Authorization header is missing, or the token is invalid or expired.No. Fix the credential.
forbidden403The credential is valid but not allowed to do this: a missing permission on the key, or a resource belonging to another account. The five slugs below are 403s too, split out because each needs a different fix.No. Resolve the underlying cause first.
suspended403This account has been suspended. Every authenticated call answers this.No. Contact support@zsender.io.
email_unverified403No user on this account has confirmed their email address yet. Blocks sending, adding a domain, and creating an API key.No. Click the link in the verification email, then retry.
domain_not_verified403The sending domain in `from` has no verified sending capability on this account.No. Publish the DNS records and re-verify the domain, then retry.
domain_blocked403The domain, or a parent of it, has been banned platform-wide by a zSender operator. Unlike domain_not_verified, there's nothing to publish in DNS to fix this.No. Contact support@zsender.io if you believe this is a mistake.
test_mode_restricted403A test-mode key (zst_<prefix>_<secret>) called an endpoint that only describes live traffic: analytics, usage, suppression writes, received mail.No. Use a live key (zs_<prefix>_<secret>).
not_found404The resource doesn't exist, or belongs to a different tenant than the caller's.No.
conflict409The request conflicts with existing state: a duplicate (an email or domain that already exists), or an Idempotency-Key whose original request is still in flight.Sometimes, once the conflicting state clears. See the message.
quota_exceeded429The tenant's monthly send quota (plan-based) has been used up.Not this billing period: no Retry-After is sent.
rate_limited429The per-minute send rate limit for the tenant's plan was exceeded.Yes. Wait for the Retry-After header, then retry.
sandbox_unavailable503A test-mode send couldn't be routed: the sandbox credentials or server aren't usable right now.Yes, after a short wait.
service_unavailable503An upstream dependency zSender needs is unreachable right now.Yes, after a short wait.
bad_gateway502The upstream mail server rejected the request or failed unexpectedly. The reason is in `message`; this slug never carries `details`.Often. It depends on the reason; back off and retry.
internal_error500An unexpected error on our side. The response body is generic on purpose: no internal detail is leaked.Yes, with backoff. If it persists, contact support.

Query strings are strict

Every documented endpoint validates its query string against a fixed schema. An unknown query parameter, or a parameter repeated more than once, is a 400 validation_error, never a silently ignored typo. This applies even to routes that take no query parameters at all: a stray ?debug=1 on a route that accepts none is still rejected.

Request bodies are strict too

Most documented request bodies reject an unrecognised field outright rather than silently dropping it: the same 400 validation_error. Sending a field the API doesn't know about is a bug worth surfacing, not a no-op.

Rate limits and Retry-After

A 429 rate_limited response carries a Retry-After header (seconds) telling you exactly when the per-minute window resets. Wait that long and retry. A 429 quota_exceeded response does not carry one: a monthly quota isn't something a short wait fixes, so there's no meaningful delay to advertise. X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset are not sent on every response. They come from a rate limiter, so they appear only where one is attached, and on every response from such a route rather than just the rejections. On the surface documented here that is two routes: POST /v1/emails/send, carrying the per-minute send limit your plan sets, and POST /v1/domains/:id/verify, carrying a separate per-account ceiling on DNS re-checks. Read them where you find them; don't build back-pressure that assumes every response has them.