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 credentials (an API key, or the dashboard session token), or a dashboard session started before the account's sessions were revoked (Sign in again.) | Check the Authorization: Bearer header; in the dashboard, sign in again |
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 |
NO_WORKSPACE | 403 | The account is signed in but has no workspace | Create the workspace from the dashboard, or sign in with the account that belongs to your workspace; see below |
TENANT_SUSPENDED | 403 | The workspace is suspended: it is read-only, so it cannot send or change anything. Also returned when creating a workspace (POST /v1/workspaces) while one of the workspaces the account owns is suspended (Your account has a suspended workspace: a new one cannot be created.) | Reply to the suspension notice sent to the workspace owner, or write to support@tratto.email |
TENANT_CLOSED | 403 | The workspace has been deleted: its API keys are refused, and so is a dashboard session whose Tratto-Workspace header names it | Nothing to retry. See Workspaces |
NOT_A_MEMBER | 403 | A dashboard session acts on a workspace the account cannot access: the account was removed from it, or the Tratto-Workspace header names a workspace the account neither owns nor is an active member of | Sign in again to return to your own workspace. With the header, send the id of a workspace the account can access, or leave it out to use the default one. See Authentication |
REAUTH_REQUIRED | 401 | The action needs a more recent sign-in: within the last 5 minutes for workspace deletion, or after the last revocation of the account's sessions to accept an invite | Sign in again in the dashboard, then repeat the action |
MEMBER_INVITES_RESTRICTED | 403 | Inviting members is not available for this workspace: invites are being opened gradually | Write to support@tratto.email. See Team |
PLAN_UPGRADE_REQUIRED | 403 | The workspace's plan does not include the feature. On Free: creating a webhook, sending a test delivery or rotating its secret (requires API 0.23.0 or later); uploading an attachment or sending an email with attachments (requires API 0.29.0 or later) | The account owner upgrades the plan in the dashboard under Settings > Billing (/settings/billing). See Webhooks and Attachments |
EMAIL_NOT_VERIFIED | 403 | The action needs a verified email address on the account (workspace deletion) | Verify the address: the dashboard offers to send the verification email |
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, deleting a template that a flow or an unfinished campaign uses, or accepting an invite that has expired; the template cases are in Templates, the invite cases are listed in Team). On POST /v1/workspaces: an account that already owns 10 workspaces counting those being deleted (see Workspaces) | Fetch the resource and check its state before retrying. For a workspace creation, write to support@tratto.email |
VALIDATION_ERROR | 422 | The request body parsed but failed schema validation, or the Tratto-Workspace header is not a valid workspace id | For the body, read details for the offending fields. For the header there is no details: message is The Tratto-Workspace header is not a workspace id.; send the id of a workspace the account can access, or leave the header out. See Authentication |
TEMPLATE_NOT_PUBLISHED | 422 | The template named in the request is still a draft and cannot be used to send | Publish it (PATCH /v1/templates/:id with status: "published"), then retry the request |
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, 500/minute per client IP, 1 attachment upload/second per API key, or too many concurrent requests on the same account | Back off and retry, honouring Retry-After when the response sets it |
PAYLOAD_TOO_LARGE | 413 | The request body exceeds the maximum allowed size. For POST /v1/attachments, a file over 25 MB | Split the payload (for contact import, upload fewer rows per request). For an attachment, send a link to the file instead |
QUOTA_EXCEEDED | 429 | A usage limit was reached: the monthly email quota (on POST /v1/emails when the email has more distinct recipients in to, cc and bcc than the account has emails left, each recipient counting as one email, or when sending a campaign now to more recipients than the quota has emails left), the domain limit (when adding a domain), the monthly AI generations in the dashboard, the daily test-mode cap of 100 test emails, the daily cap of test-sends per workspace (20 on Free, 100 on Starter and Growth, 200 on Scale, on the template and campaign test-sends, with API 0.24.0 or later), the limit of team members of the account (when inviting or accepting an invite, counting pending invites, with API 0.28.0 or later), the number of workspaces the account can own (when creating a workspace), or the attachment storage of the workspace, 1 GB (when uploading an attachment, with API 0.29.0 or later). The email quota, the domain limit, the AI generations, the workspace limit and the limit of team members belong to the account and are shared by all its workspaces | Do not retry: message names the limit. The email quota resets at the start of the next UTC month, the test-mode cap and the test-send cap at midnight UTC. For the domain limit, delete a domain or upgrade the plan; for the email quota, upgrade the plan; for the limit of people, revoke a pending invite, remove a member or upgrade the plan (see Team); for the workspace limit, delete a workspace or upgrade the plan (see Workspaces); for attachment storage, delete attachments you no longer need or wait for some to expire (see Attachments) |
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. On the campaign and template test-sends, a 503 means the email could not be queued: nothing was created, sent or charged, so the retry is safe. On POST /v1/emails a 503 means the email could not be stored or handed to the delivery queue: nothing was sent, and the message says whether it was charged (Nothing was sent and nothing was charged. or, with API 0.21.0 or later when the quota could not be given back, Nothing was sent. This request may still count against the monthly quota.). Retry after Retry-After, reusing the same Idempotency-Key if you sent one |
A missing resource has no code of its own. 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"
}
]
}
}With API 0.24.0 or later, POST /v1/emails and PATCH /v1/emails/{id} also
reject a field they do not know: the entry has path / for a field at the
top level of the body, and a message that names it, such as
Unrecognized key: "reply_to". See
Unknown fields.
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.
TEMPLATE_NOT_PUBLISHED (422)
A template has to be published before it can be used to send. This is a code
of its own, not a flavour of VALIDATION_ERROR: it carries no details array,
because no schema rejected the body. It is returned by POST /v1/campaigns, by
a PATCH /v1/campaigns/:id that swaps in a template, and by
POST /v1/flows/:id/activate:
{
"error": {
"code": "TEMPLATE_NOT_PUBLISHED",
"message": "Template 'tmpl_abc123' is not published and cannot be used to send.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Publish the template first: PATCH /v1/templates/:id with { \"status\": \"published\" }, or use the Publish button in the template editor."
}
}On flow activation the message names the step that carries the template:
Template 'tmpl_abc123' on step 's0' is not published and cannot be used to send.
A templateId that does not exist is a NOT_FOUND (404) instead. The template
test send is exempt, and POST /v1/emails is not refused at all: it accepts
the request and the email fails at delivery. See
Templates.
NO_WORKSPACE (403)
A dashboard session (a signed-in account, not an API key) whose account has no
workspace gets this code on every authenticated route, except the two that
accept an invitation, the one that creates a workspace and the one that lists
them: GET /v1/workspaces answers 200 with {"data":[]}, with or without beta
access. The beta access check runs first: an account without beta
access gets that error instead.
{
"error": {
"code": "NO_WORKSPACE",
"message": "This account has no workspace.",
"docs": "https://docs.tratto.email/en/docs/error-codes"
}
}There is no suggestion. Accepting the beta invitation does not create a
workspace, and neither does GET /v1/workspace. In the dashboard this code
opens the "This account has no workspace" page: the "Create your workspace"
button creates one and takes you into it. From there you can also sign in with
another account or try again.
The button calls POST /v1/workspaces, which accepts a dashboard session only.
It returns 201 with the same object as GET /v1/workspace. The name field is
optional (1 to 100 characters, default My Workspace): an invalid name gets
422. The same route creates the workspaces beyond the first, within the limit
of the plan; the refusals are listed in
Workspaces. Workspaces created this way have ids starting with ws_; existing
workspaces keep their id.
API keys are not affected: a key always belongs to a workspace, and a key cannot
create one (POST /v1/workspaces returns 403).
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 or a workspace is deleted, a place in the workspace is freed, or the plan is upgraded.
The monthly email quota, the domain limit and the AI generations belong to the
account that owns the workspace, and every workspace of that account draws
on them. A QUOTA_EXCEEDED can come from another workspace of the same account
or from another member, and its message says that the limit is shared across
the account. See
Rate Limits.
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" (one of the four values listed in
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
Both SDKs raise a TrattoError carrying the code and the HTTP status, and
each exposes part of the rest of the payload: the Node SDK adds docs, the
Python SDK adds suggestion and docs from 1.2.0 or newer. Branch on the
code; read suggestion when one code covers more than one situation.
Node.js
@tratto/email throws TrattoError, which carries code, statusCode and
docs:
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 'TEMPLATE_NOT_PUBLISHED':
// Raised by the campaign and flow calls. Publish the template,
// then make the call again: retrying as is cannot help.
break;
case 'RATE_LIMITED':
await backoff();
break;
case 'QUOTA_EXCEEDED':
// Nothing to retry.
break;
default:
throw error;
}
}
}Python
tratto-email raises TrattoError with code, status_code, suggestion and
docs. The last two need 1.2.0 or newer and are None when the response
omits them:
from tratto import TrattoError
try:
client.campaigns.unschedule('camp_xyz789')
except TrattoError as e:
print(e.code) # 'CONFLICT'
print(e.suggestion) # what to do instead, in the API's own words
print(e.docs) # link to this pagePHP
tratto/email throws TrattoException with errorCode, statusCode,
suggestion and docs. The last two are null when the response omits
them:
use Tratto\TrattoException;
try {
$tratto->campaigns->unschedule('camp_xyz789');
} catch (TrattoException $e) {
echo $e->errorCode, "\n"; // 'CONFLICT'
echo $e->suggestion, "\n"; // what to do instead, in the API's own words
echo $e->docs, "\n"; // link to this page
}This is the case suggestion exists for: the 409 on campaigns.send and on
campaigns.unschedule is one code covering several states, and only the text
says which one you hit. See
Campaigns.
Next: Rate Limits · Idempotency
Edit this page on GitHub
Last updated on