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.
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 |
| 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. |
| 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 |
| 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. |
| Code |
Meaning |
NOT_FOUND |
ID doesn't exist in your org / tenant. |
| Code |
Recover by |
INTERNAL_ERROR |
Retry; if persistent quote X-Request-Id in support. |
UNKNOWN |
Same — surfaces when the JSON response was unparseable. |
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.
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()));
}
}
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
| 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. |