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": "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.
| Slug | Status | Meaning | Retryable |
|---|---|---|---|
| validation_error | 400 | The request body, query string, or a path parameter failed validation. | No. Fix the request first. |
| unauthorized | 401 | The Authorization header is missing, or the token is invalid or expired. | No. Fix the credential. |
| forbidden | 403 | The 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. |
| suspended | 403 | This account has been suspended. Every authenticated call answers this. | No. Contact support@zsender.io. |
| email_unverified | 403 | No 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_verified | 403 | The 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_blocked | 403 | The 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_restricted | 403 | A 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_found | 404 | The resource doesn't exist, or belongs to a different tenant than the caller's. | No. |
| conflict | 409 | The 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_exceeded | 429 | The tenant's monthly send quota (plan-based) has been used up. | Not this billing period: no Retry-After is sent. |
| rate_limited | 429 | The per-minute send rate limit for the tenant's plan was exceeded. | Yes. Wait for the Retry-After header, then retry. |
| sandbox_unavailable | 503 | A test-mode send couldn't be routed: the sandbox credentials or server aren't usable right now. | Yes, after a short wait. |
| service_unavailable | 503 | An upstream dependency zSender needs is unreachable right now. | Yes, after a short wait. |
| bad_gateway | 502 | The 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_error | 500 | An 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.