Error Codes Reference
The error codes the Tratto API returns, and what to do about each.
The API returns a small set of error codes. They are deliberately generic: the code
tells you the class of problem and the HTTP status, while message carries the
specific detail.
Branch on code, not on message: messages are written for humans and can
change without notice.
Error Format
{
"error": {
"code": "NOT_FOUND",
"message": "Webhook 'wh_abc123' not found.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}suggestion is present on some errors with a hint about the fix. Validation
errors add a details array: see below.
The Codes
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid API key | Check the Authorization: Bearer header |
FORBIDDEN | 403 | The request is not allowed: the key lacks a permission, the sending domain is not verified, or the action is blocked for this resource | Read message and suggestion; for a missing permission, use a key that has it |
NOT_FOUND | 404 | The resource does not exist | Check the ID; it may belong to another workspace |
CONFLICT | 409 | The resource already exists, or its current state does not allow the action (for example, editing a campaign that is no longer a draft) | Fetch the resource and check its state before retrying |
VALIDATION_ERROR | 422 | The request body parsed but failed schema validation | Read details for the offending fields |
BAD_REQUEST | 400 | The request never reached a handler — the body could not be parsed (malformed JSON) | Fix the body syntax; the payload never got as far as the schema |
UNSUPPORTED_MEDIA_TYPE | 415 | The Content-Type is not one this endpoint parses | Send the type the endpoint expects (application/json for most, text/csv for contact import, image/png or image/jpeg for the brand logo) |
INVALID_TOKEN | 400 | A signed link (unsubscribe, preference page) is not valid | Nothing to retry — request a fresh link |
RATE_LIMITED | 429 | Too many requests: 100/second per API key, or 500/minute per client IP | Back off exponentially and retry |
PAYLOAD_TOO_LARGE | 413 | The request body exceeds the maximum allowed size | Split the payload (for contact import, upload fewer rows per request) |
QUOTA_EXCEEDED | 429 | A usage limit was reached: the monthly email quota (on POST /v1/emails, or when sending a campaign now to more recipients than the quota has left), the domain limit (when adding a domain), or the daily test-mode cap | Do not retry: message names the limit. The email quota resets at the start of the next UTC month, the test-mode cap at midnight UTC. For the domain limit, delete a domain or upgrade the plan; for the email quota, upgrade the plan |
TEST_MODE_NOT_SUPPORTED | 403 | The endpoint reaches real recipients and rejects test keys | Use a live key (tratto_live_...) — see Test mode |
IDEMPOTENCY_CONFLICT | 409 | The Idempotency-Key was reused with a different payload | Use a fresh key for a different request |
IDEMPOTENCY_IN_PROGRESS | 409 | A request with this Idempotency-Key is still being processed | Wait for the original request to finish, then retry |
INTERNAL_ERROR | 500 | Something failed on our side | Retry with backoff; contact support if it persists |
SERVICE_UNAVAILABLE | 503 | A service this endpoint depends on is switched off, saturated or not responding | Retry later, honouring Retry-After when the response sets it; suggestion says whether waiting helps |
There are no resource-specific codes. A missing template, a missing contact and
a missing campaign all return NOT_FOUND. The resource is named in message,
not in code.
BAD_REQUEST (400) vs VALIDATION_ERROR (422)
Both mean "your request was wrong", and they fail at different stages:
BAD_REQUEST(400) — the request never reached a handler. The body could not be parsed at all: malformed JSON, a truncated payload, aContent-Typethat says JSON over something that isn't. There is nodetailsarray, because no schema ever ran.VALIDATION_ERROR(422) — the body parsed, then failed the schema. This is the one that tells you which field is wrong.
Changed on 2026-09-04. Three cases used to return 500 INTERNAL_ERROR and
no longer do: an empty body sent with Content-Type: application/json —
the shape curl and Postman produce by default for a mutation with no fields,
such as POST /v1/campaigns/{id}/pause, POST /v1/campaigns/{id}/unschedule
or any DELETE — now succeeds normally (200/204/409); malformed JSON now
returns 400 BAD_REQUEST; an unhandled Content-Type now returns
415 UNSUPPORTED_MEDIA_TYPE. If your client treats 5xx as retryable and 4xx as
final, that classification is now correct for these three.
Validation errors
VALIDATION_ERROR returns 422 and includes a details array from
the schema validator identifying each field that failed:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"details": [
{
"path": "/to",
"message": "Invalid email"
}
]
}
}Markdown validation
The markdown support on POST /v1/emails and on
templates adds two VALIDATION_ERROR (422) cases worth knowing:
markdownandhtmltogether. The two fields are mutually exclusive everywhere. OnPOST /v1/emailsthe message is"markdown and html are mutually exclusive — provide one or the other."; on templates,htmlis rejected outright forformat: "emailmd"("html is not accepted when format is 'emailmd' — the HTML is derived from the markdown render.") andmarkdownrequires that format.- Empty markdown. A
markdownfield that is empty or whitespace-only fails with"markdown must not be empty."— the render never runs on empty input.
Two codes share 429
RATE_LIMITED and QUOTA_EXCEEDED both return 429, and they need opposite
responses:
RATE_LIMITED: you are sending too fast. Back off and retry; the request will succeed shortly. See Rate Limits.QUOTA_EXCEEDED: you have hit a usage limit, such as the monthly email quota. Retrying will not help until the limit resets, a domain is deleted, or the plan is upgraded.
Work that runs in the background never answers with this code. A campaign the
dispatcher cannot fit in the remaining quota, before it starts or mid-send, is
paused with pausedReason: "quota_exceeded" (see
Campaigns); resuming it later reaches only the recipients
it missed. A flow whose send step finds the quota exhausted skips that email
and moves on: the email is not sent later.
Treating them the same means retrying forever against a quota wall:
if (error.status === 429) {
if (error.code === 'QUOTA_EXCEEDED') {
// Alert someone. Retrying cannot fix this.
throw error;
}
await backoff();
return retry();
}Handling errors with the SDK
@tratto/email throws TrattoError, which carries code and statusCode:
import { TrattoError } from '@tratto/email';
try {
await tratto.emails.send({ /* … */ });
} catch (error) {
if (error instanceof TrattoError) {
switch (error.code) {
case 'VALIDATION_ERROR':
// Fix the payload: retrying unchanged will fail again.
break;
case 'RATE_LIMITED':
await backoff();
break;
case 'QUOTA_EXCEEDED':
// Nothing to retry.
break;
default:
throw error;
}
}
}Next: Rate Limits · Idempotency
Edit this page on GitHub
Last updated on