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

CodeHTTPMeaningWhat to do
UNAUTHORIZED401Missing 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
FORBIDDEN403The request is not allowed: the key lacks a permission, the sending domain is not verified, or the action is blocked for this resourceRead message and suggestion; for a missing permission, use a key that has it
NO_WORKSPACE403The account is signed in but has no workspaceCreate the workspace from the dashboard, or sign in with the account that belongs to your workspace; see below
TENANT_SUSPENDED403The 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_CLOSED403The workspace has been deleted: its API keys are refused, and so is a dashboard session whose Tratto-Workspace header names itNothing to retry. See Workspaces
NOT_A_MEMBER403A 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 ofSign 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_REQUIRED401The 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 inviteSign in again in the dashboard, then repeat the action
MEMBER_INVITES_RESTRICTED403Inviting members is not available for this workspace: invites are being opened graduallyWrite to support@tratto.email. See Team
PLAN_UPGRADE_REQUIRED403The 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_VERIFIED403The action needs a verified email address on the account (workspace deletion)Verify the address: the dashboard offers to send the verification email
NOT_FOUND404The resource does not existCheck the ID; it may belong to another workspace
CONFLICT409The 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_ERROR422The request body parsed but failed schema validation, or the Tratto-Workspace header is not a valid workspace idFor 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_PUBLISHED422The template named in the request is still a draft and cannot be used to sendPublish it (PATCH /v1/templates/:id with status: "published"), then retry the request
BAD_REQUEST400The 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_TYPE415The Content-Type is not one this endpoint parsesSend the type the endpoint expects (application/json for most, text/csv for contact import, image/png or image/jpeg for the brand logo)
INVALID_TOKEN400A signed link (unsubscribe, preference page) is not validNothing to retry — request a fresh link
RATE_LIMITED429Too 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 accountBack off and retry, honouring Retry-After when the response sets it
PAYLOAD_TOO_LARGE413The request body exceeds the maximum allowed size. For POST /v1/attachments, a file over 25 MBSplit the payload (for contact import, upload fewer rows per request). For an attachment, send a link to the file instead
QUOTA_EXCEEDED429A 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 workspacesDo 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_SUPPORTED403The endpoint reaches real recipients and rejects test keysUse a live key (tratto_live_...) — see Test mode
IDEMPOTENCY_CONFLICT409The Idempotency-Key was reused with a different payloadUse a fresh key for a different request
IDEMPOTENCY_IN_PROGRESS409A request with this Idempotency-Key is still being processedWait for the original request to finish, then retry
INTERNAL_ERROR500Something failed on our sideRetry with backoff; contact support if it persists
SERVICE_UNAVAILABLE503A service this endpoint depends on is switched off, saturated or not respondingRetry 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, a Content-Type that says JSON over something that isn't. There is no details array, 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:

  • markdown and html together. The two fields are mutually exclusive everywhere. On POST /v1/emails the message is "markdown and html are mutually exclusive — provide one or the other."; on templates, html is rejected outright for format: "emailmd" ("html is not accepted when format is 'emailmd' — the HTML is derived from the markdown render.") and markdown requires that format.
  • Empty markdown. A markdown field 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 page

PHP

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