API errors

Every error response follows the same shape:

{
  "error": {
    "type": "validation_error",
    "message": "Subject is required",
    "code": "VALIDATION_ERROR",
    "request_id": "req_Hg8JpNvKXWg4f",
    "errors": [
      { "path": ["to", 0], "code": "invalid_string", "message": "Invalid recipient email" }
    ]
  }
}
  • type — high-level category (also matches HTTP status below).
  • code — stable identifier for programmatic handling; see catalog.
  • message — human string; may reference the first field issue.
  • request_id — also emitted as the X-Request-Id header. Include in support tickets.
  • errors[] — present on validation errors from a Zod schema. Each row has path (array of keys / indexes), code (Zod issue code), message.

Types → HTTP status

type HTTP When
authentication_error 401 Missing / invalid / revoked / expired key
authentication_error 403 Key lacks scope, IP not allowed, tenant mismatch
validation_error 422 or 409 Body failed validation, or idempotency mismatch
rate_limit_error 429 Burst limit, plan quota, warmup cap, in-flight idempotency, replay cap
not_found_error 404 ID unknown or not owned by your org / tenant
server_error 500 Unhandled exception

Full code catalog

Authentication — 401 / 403

Code HTTP Meaning Recover by
UNAUTHORIZED 401 Missing Authorization header or invalid/revoked/expired key Check Authorization: Bearer sok_live_.... Rotate if compromised.
INSUFFICIENT_SCOPE 403 Key doesn't include the scope required for this endpoint Recreate the key with the listed scope (authentication.md).
IP_NOT_ALLOWED 403 Request IP is outside the key's allowed_cidrs list Add the caller IP / CIDR to the key, or send from an allowed network.
TENANT_FORBIDDEN 403 Tenant-bound key tried to act on a different tenant's resource Use a platform-root key, or the correct tenant's key.
TENANT_KEY_CANNOT_CREATE_TENANTS 403 Tenant-bound key attempted POST /tenants Use a platform-root key.
TENANT_KEY_CANNOT_DELETE 403 Tenant-bound key attempted DELETE /tenants/:id Use a platform-root key.
DOMAIN_TENANT_MISMATCH 403 from address belongs to a different tenant Send from a domain owned by the key's tenant (or a null-tenant shared domain).
DOMAIN_NOT_ALLOWED_FOR_KEY 403 from domain isn't in the key's allowed_domain_ids[] Send from an allowed domain, or add this domain to the key's allowlist.

Validation — 409 / 413 / 422

Code HTTP Meaning
VALIDATION_ERROR 422 Zod schema failed. Check error.errors[] for per-field issues.
IDEMPOTENCY_MISMATCH 409 Idempotency-Key reused with a different request body
IDEMPOTENCY_IN_FLIGHT 409 Another request with this key is still running
ALL_SUPPRESSED 422 Every recipient is on the suppression list (tenant + platform scopes)
SUPPRESSED 422 Single SMS recipient (or batch item recipient) is suppressed.
NOT_SCHEDULED 409 Tried to DELETE a message that isn't in scheduled state.
ATTACHMENT_TOO_LARGE 413 Combined inline attachment size > 7 MB (headroom under SES's 10 MB raw cap)
PAYLOAD_TOO_LARGE 413 Request body > 6 MB single-send / 10 MB batch / 10 MB inbound webhook
TEMPLATE_MISSING_VARIABLES 422 Template references {{var}} but variables didn't supply them
AUDIENCE_TOO_LARGE 413 Audience > 10 000 recipients in a single send call
TENANT_SLUG_CONFLICT 409 Tenant slug already in use in this org.
SENDER_NOT_REGISTERED 422 Live SMS send: the from value has no matching phone_numbers row (looked up by e164 for E.164, sender_id for alphanumeric)
SENDER_NOT_VERIFIED 422 The phone_numbers row exists but status != verified
COUNTRY_NOT_PROVISIONABLE 422 kind: "number" provision with an iso_country AWS doesn't allocate via API (anything outside US/CA)
SENDER_ALREADY_REGISTERED 409 Tried to register an alphanumeric sender_id that's already on the org
WEBHOOK_URL_NOT_ALLOWED 422 Webhook URL resolves to a private / loopback / metadata IP, or uses a blocked protocol

Rate / quota — 429

Code Meaning Recover by
RATE_LIMITED Burst (per-plan or per-key) exceeded Back off until X-RateLimit-Reset. Free=60/min, Pro=600, Enterprise=6000.
USAGE_LIMIT_EXCEEDED Monthly plan email/SMS quota reached Upgrade or wait for next period.
WARMUP_LIMIT_EXCEEDED Domain is in warmup window; today's cap reached Spread sends across days, or finish warmup.
REPLAY_RATE_LIMITED Too many webhook replays on this endpoint 10/min per endpoint (shared by single + bulk). Wait.
TEST_FIRE_RATE_LIMITED Too many synthetic events triggered 20/min per endpoint. Wait.
ENDPOINT_DISABLED (409) Replay/test-fire against a disabled webhook endpoint Re-enable via PATCH /v1/webhooks/:id { "enabled": true } — clears the auto-disable streak.

Not found — 404

Code Meaning
NOT_FOUND ID doesn't exist in your org / tenant.

Server — 500

Code Recover by
INTERNAL_ERROR Retry; if persistent quote X-Request-Id in support.
UNKNOWN Same — surfaces when the JSON response was unparseable.

Provider — 502

When a downstream provider (AWS SES / SNS / Stripe) returns an error we couldn't classify, you'll see type: "server_error" with a 502 and the provider's message copied into message. Log the request_id and the provider trace if possible.

Reading validation errors

try {
  await mr.emails.send({ from, to, subject, html });
} catch (err) {
  if (err.code === "VALIDATION_ERROR") {
    for (const issue of err.errors ?? []) {
      console.log(issue.path.join("."), issue.message);
    }
  } else if (err.code === "RATE_LIMITED") {
    await new Promise((r) => setTimeout(r, reset - Date.now()));
  }
}

Headers

Returned on every response, not just 429:

X-Request-Id: req_Hg8JpNvKXWg4f
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 1713820800
X-RateLimit-Plan: pro
X-RateLimit-Scope: org | key

Recovery recipes

What happened Do this
429 RATE_LIMITED Sleep until X-RateLimit-Reset (unix ms), then retry.
429 USAGE_LIMIT_EXCEEDED Upgrade or wait; no retry helps.
409 IDEMPOTENCY_IN_FLIGHT Retry in ~500 ms.
409 IDEMPOTENCY_MISMATCH Generate a fresh UUID key.
422 VALIDATION_ERROR Inspect errors[]; fix fields; do not retry blindly.
422 TEMPLATE_MISSING_VARIABLES Include the missing {{var}} in variables.
403 DOMAIN_TENANT_MISMATCH Send from a domain your tenant owns.
500 / 502 Retry with exponential backoff; after 3 failures, open a ticket with request_id.