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 API keyCheck the Authorization: Bearer header
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
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)Fetch the resource and check its state before retrying
VALIDATION_ERROR422The request body parsed but failed schema validationRead details for the offending fields
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, or 500/minute per client IPBack off exponentially and retry
PAYLOAD_TOO_LARGE413The request body exceeds the maximum allowed sizeSplit the payload (for contact import, upload fewer rows per request)
QUOTA_EXCEEDED429A 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 capDo 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_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

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, 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"
      }
    ]
  }
}

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.

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