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 — coarse category, one of five values (below). It does not map 1:1 to the HTTP status — branch on code and the status, not on type.
  • code — stable identifier for programmatic handling; see catalog.
  • message — human string; may reference the first field issue.
  • request_id — injected by withApiAuth on every /v1 error response; 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

type says which kind of thing went wrong; the HTTP status says whether it is worth retrying. The two are set independently, so the same type is seen under several statuses:

type What it means Statuses it is emitted under
authentication_error The credential, its scope, its restrictions, or the org/tenant it belongs to refused the request. Retrying unchanged never succeeds 401 (no usable key), 403 (key lacks scope / IP not allowed / escalation refused / org suspended / tenant unusable / unverified sender domain / sandbox sender recipient / SMS destination not allowed), 409 (TENANT_NOT_USABLE — the auto-resolved tenant is suspended or archived)
validation_error The request body, a query param, or the current state of the resource makes the operation impossible as asked. Fix the input or the state, then retry 422 (input), 409 (state conflict — idempotency mismatch, slug taken, not scheduled, tenant lifecycle), 413 (payload / attachment / audience too large), 502 (SEND_FAILED — the provider refused the verification code; typed validation_error even though the fault is downstream), 503 (UNAVAILABLE — a verification could not be started or its check recorded; ours, retry)
rate_limit_error A limit — burst, monthly quota, spend cap, plan resource cap, provisioning or replay budget, in-flight lock — refused the request. Retry after the window or raise the cap 429 (burst, quota, provisioning, replay, key-create, tenant auto-create, sandbox daily cap), 402 (SPEND_CAP_EXCEEDED, PLAN_RESOURCE_LIMIT, DEDICATED_IP_LIMIT_EXCEEDED — a paid cap, not a window), 409 (IDEMPOTENCY_IN_FLIGHT — the same key is still running)
not_found_error The id does not exist in your org / project / tenant / environment. A row another key can see is still a 404 to you 404
server_error Ours, or the provider's. Retry with backoff and quote request_id if it persists 500 (unhandled), 502 (PROVIDER_UNAVAILABLE, PROVIDER_EMPTY)

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.
ORG_SUSPENDED 403 The organization has been suspended by an operator. Every /v1 request is refused; the dashboard stays reachable so an owner can see why Contact support@sendoka.com. Nothing in the API clears this — an operator lifts it.
SMS_DESTINATION_NOT_ALLOWED 403 The destination's country isn't on the org's allowed SMS destinations list. Surfaces on POST /v1/sms, POST /v1/verifications (SMS channel), per-item in POST /v1/sms/batch, and on POST /v1/audiences/{id}/send (SMS channel), where one off-list recipient refuses the whole blast An owner edits the list in Settings → SMS destinations. Shared dial codes (+1, +44, …) pass if any country sharing the prefix is allowed.
SENDER_DOMAIN_NOT_VERIFIED 403 The from address's domain is not a verified domain of this org — either it was never added, or it is still pending DNS. Live email sends only; test-mode sends skip the check Add and verify it via POST /v1/domains and publish the DNS records, or send from your org's sandbox sender (which only reaches your own members — see SANDBOX_RECIPIENT_NOT_ALLOWED). That hostname is per-org ({org-slug}.sandbox.sendoka.com, not sandbox.sendoka.com) — copy the exact one from Overview → Domains.
SANDBOX_RECIPIENT_NOT_ALLOWED 403 Live email from your org's sandbox sender ({org-slug}.sandbox.sendoka.com) to an address that is not the verified sign-in address of a member of your org. Every to, cc and bcc is judged: after suppression on POST /v1/emails, per item on the full list in POST /v1/emails/batch. When a refused address belongs to a member who has not verified it yet, the message says so. Also refuses any audience broadcast from the sandbox sender outright, whoever is in the audience. Surfaces on POST /v1/emails (immediate and scheduled), per-item in POST /v1/emails/batch, POST /v1/audiences/{id}/send, email POST /v1/verifications and the dashboard send surfaces; a scheduled send that fails it at fire time is canceled with this message. Test keys are exempt Send to a member's verified sign-in address, or add and verify your own domain via POST /v1/domains and send from that to reach anyone else. A member whose own address is refused as not verified yet verifies it in the dashboard under Settings → Your sign-in email → Send verification link (the session route POST /api/auth/resend-verification).

Validation — 409 / 413 / 422

Code HTTP Meaning Recover by
VALIDATION_ERROR 422 Zod schema failed. Check error.errors[] for per-field issues.
INVALID_CURSOR 422 cursor isn't a base64url({ISO}|{id}) value minted by a previous page Pass next_cursor from the previous response verbatim, or omit it to start over.
INVALID_TENANT_REF 422 X-Sendoka-Tenant-Ref must be a non-empty string of ≤ 200 characters Send a valid external ref (the shop domain, typically).
INVALID_EXTERNAL_REF 422 GET /v1/tenants?external_ref= was empty, only whitespace, or over 200 characters. It 422s rather than being dropped — a silently ignored filter returns the unfiltered first page, which is how a duplicate-tenant guard can run for months while every re-provision splits one shop across two tenants Send the ref you stored on the tenant; check the variable holding it isn't unset or blank.
IDEMPOTENCY_MISMATCH 409 Idempotency-Key reused with a different request body Use a fresh key, or submit the byte-identical body.
IDEMPOTENCY_IN_FLIGHT 409 Another request with this key is still running Retry after a few hundred ms. The lock's TTL is 330s, but it is released as soon as the response is recorded, so on the send routes the window is effectively a few hundred ms. On POST /v1/audiences/{id}/send it is held for the whole blast, which can take minutes — retry later under the same key to get the job_id. A lock whose response failed to record is kept for its full TTL rather than freed with nothing replayable under it. An audience send cut off mid-blast answers this for 330s after its job was created, then replays the job as AUDIENCE_SEND_INCOMPLETE. In production an unreachable Redis also answers this code, since the lock fails closed.
ALL_SUPPRESSED 422 Every recipient is on the suppression list (tenant + platform scopes). Which rows count depends on the send's stream — a transactional email is blocked by all rows only, a broadcast one by all plus one-click unsubscribes Scrub the list, or remove from suppressions if deliberate. Check you meant the stream you sent.
TO_ALL_SUPPRESSED 422 Some recipients survived the filter, but every address in to was suppressed — so the send would deliver only to cc/bcc Drop the suppressed to entries, or move a live address into to.
SUPPRESSED 422 Single SMS recipient (or batch item recipient) is suppressed. Per-item in a batch: the rest of the batch still sends Scrub that recipient, or remove the suppression if deliberate.
UNSUBSCRIBE_REQUIRES_BROADCAST 422 {{unsubscribe_url}} appeared in html / text on a send left on the default stream: "transactional". A body carrying an unsubscribe link is list mail, and a transactional send never reads the broadcast suppressions such a link writes — so honouring it would be a promise the next send breaks Set "stream": "broadcast" on the send (or the batch item), or drop the token.
UNSUBSCRIBE_MULTI_RECIPIENT 422 {{unsubscribe_url}} with more than one destination across to + cc + bcc. The token binds to a single address, so on a shared envelope one recipient's click would unsubscribe to[0] Split into one send per recipient, or use the bulk shape of /v1/emails/batch, which fans one body out to one envelope each.
NOT_SCHEDULED 409 Tried to DELETE a message that isn't in scheduled state.
SCHEDULED_AT_IN_PAST 422 scheduled_at is more than 5 minutes before server time. The grace window is deliberate clock-skew tolerance — inside it the send is accepted and goes out immediately Send a future timestamp, or omit scheduled_at to send now.
SCHEDULED_AT_TOO_FAR 422 scheduled_at is more than 366 days ahead Schedule within a year, or store the intent on your side and call us closer to the time.
ATTACHMENT_TOO_LARGE 413 Combined inline attachment size > 7 MB (headroom under SES's 10 MB raw cap) Use url attachments instead of inline base64, or split.
PAYLOAD_TOO_LARGE 413 Request body > 6 MB single-send / 10 MB batch / 10 MB inbound webhook. Also a templated send that passes a render cap (POST /v1/emails, POST /v1/sms, audience sends): its {{#each}} blocks run more than 5,000 iterations in total or render more than 2,000,000 characters, or one part renders more than 10,000,000 characters; it is refused, never sent cut short. And POST /v1/audiences/{id}/send when one recipient's stored message (subject + HTML + text after tracking, plus headers) passes 900,000 bytes, or the whole blast passes 500,000,000; the message names the contact and nothing is scheduled Split the request, or use url attachments. For a template, shrink the loop body or the arrays its {{#each}} blocks loop over. For a blast past the total, split the audience.
TEMPLATE_MISSING_VARIABLES 422 Template references {{var}} but variables didn't supply them. On POST /v1/audiences/{id}/send it is checked against every recipient after the per-contact merge, and only a placeholder that resolves for no recipient refuses the blast (not yet for a tenant-scoped key, whose blast renders it empty as before); one that some contacts lack renders empty for them Include the missing keys, or omit the template placeholders. For a blast, fix the typo, or pass a value in variables (a contact's metadata still overrides it).
AUDIENCE_TOO_LARGE 413 Audience > 10 000 recipients in a single send call Split the audience.
TENANT_SLUG_CONFLICT 409 Tenant slug already in use in this org.
TENANT_ACTIVE 409 POST /v1/tenants/:id/erase against a tenant that is still active. Erasure is irreversible, and an active tenant is one somebody is still sending for — refusing is the guard Archive or suspend the tenant first, then erase.
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) Provision a number via /v1/phone-numbers (US/CA) or register an alphanumeric sender via kind: "alphanumeric" (UK/AU/EU).
SENDER_NOT_VERIFIED 422 The phone_numbers row exists but status != verified Wait for the AWS verification poll (5 min) or PATCH with empty body to re-check. Alphanumeric senders verify instantly.
COUNTRY_NOT_PROVISIONABLE 422 kind: "number" provision with an iso_country AWS doesn't allocate via API (anything outside US/CA) Use kind: "alphanumeric" for UK/AU/EU, or port a number in via your AWS support contact.
SENDER_ALREADY_REGISTERED 409 Tried to register an alphanumeric sender_id that's already on the org Pick a different sender ID or DELETE the existing one.
BRAND_NOT_FOUND 422 brand_id is unknown, or belongs to another org / project Use a brd_ id from GET /v1/brands.
BRAND_NOT_VERIFIED 422 A 10DLC campaign was created against a brand the registry has not approved yet Poll GET /v1/brands/:id until status: "verified". Toll-free campaigns do not reference a brand.
BRAND_IS_TEST_MODE 422 Live campaign create against a brand registered with a sok_test_ key — it carries a simulated test-reg-… id and no registry registration Register the brand with a live key. Test-mode resources cannot back live ones.
BRAND_IS_LIVE_MODE 422 PATCH /v1/brands/:id with a sok_test_ key against a brand a test key did not create. A test key may only correct a brand carrying a simulated test-reg-… id; every other brand is refused, including one with no registration id at all — a test-mode create always leaves a test-reg-… id behind, so a null id can only have come from a live create whose provider call failed Use a live key to correct a brand that was registered with one.
BRAND_HAS_CAMPAIGNS 409 DELETE /v1/brands/:id while a campaign still references it Delete the campaigns first. The guard is global — a campaign in any project blocks it.
CAMPAIGN_NOT_FOUND 422 campaign_id is unknown, or belongs to another org / project.
CAMPAIGN_NOT_VERIFIED 422 Bound a number to a campaign the registry has not approved yet Poll GET /v1/campaigns/:id until status: "verified".
CAMPAIGN_IS_TEST_MODE 422 Live number binding against a campaign registered with a sok_test_ key Register the campaign with a live key. Applies on provision and on PATCH /v1/phone-numbers/:id.
NUMBER_ALREADY_REGISTERED 409 The provisioned E.164 is already on this org Only reachable in test mode, where numbers are drawn from a reserved range; retry.
NO_FIELDS 422 PATCH /v1/brands/:id with a body carrying no updatable field Send at least one field from the UpdateBrand schema.
WEBHOOK_URL_NOT_ALLOWED 422 Webhook URL resolves to a private / loopback / metadata IP, or uses a blocked protocol Point the endpoint at a publicly reachable HTTPS URL.
DLT_TEMPLATE_REQUIRED 422 "Sending to +91 (India) requires dlt_template_id referencing an approved DLT template." India DLT enforcement is on by default and applies in test mode as well as live. Reachable from POST /v1/verifications as well as the SMS routes — a +91 OTP needs its own registered template Pass dlt_template_id; register templates at /overview/dlt. See sms.md.
DLT_TEMPLATE_NOT_FOUND 422 "dlt_template_id '<id>' not found for this org." Use a dlt_ id from this org's template list.
DLT_TEMPLATE_NOT_APPROVED 422 "DLT template is in '<status>' status; only 'approved' templates can be used." Batch + audience send also return this code for unknown or unprovisioned ids Wait for dlt-status-poll to flip the template to approved, or fix the rejection and re-submit.
DLT_TEMPLATE_NOT_PROVISIONED 422 Approved locally but the DLT provider issued no entity PEID / provider template id, so the carrier cannot scrub the message Re-submit the entity and template to the DLT provider (also seen under DLT_PROVIDER=none / DLT_DRY_RUN=true).
HIGH_COST_DESTINATION 422 The destination sits in a high-cost non-geographic range (+870, +878, +881/+8816, +882, +883 — satellite and international networks that bill dollars a message). Refused before the allowlist and in every environment: no allowlist can open a range Sendoka does not serve. Surfaces on POST /v1/sms, POST /v1/verifications, per-item in POST /v1/sms/batch, on the dashboard template test-send, and on POST /v1/audiences/{id}/send (SMS), where one such recipient refuses the whole blast Remove the recipient. Only well-formed E.164 numbers are judged, so a blast is never refused over a locally-formatted stored number.
SUPPRESSION_PROTECTED 409 DELETE /v1/suppressions against a row whose reason is stop (the recipient texted STOP) or complaint (the recipient reported spam). Those are the recipient's decision, so no surface removes them — not the API, the dashboard or the MCP tool. bounce, manual and unsubscribe rows delete normally Nothing on your side lifts it, and retrying will not either. Re-subscription needs a new action by the recipient: a fresh SMS opt-in, or a new email subscription.
QUERY_BODY_CONFLICT 422 DELETE /v1/suppressions named its target in both the query string and the JSON body, and they disagree — e.g. ?value=a@example.com with { "value": "b@example.com" }. Each field may come from either place; one sent in both must name the same thing (value is compared case-insensitively). Nothing was removed Send channel and value in one place, or the same values in both.
PROJECT_NOT_EMPTY 409 DELETE /v1/projects/:id while any row still references the project. Projects have no cascade, and the guard covers every table with a project_id FK — keys, domains, messages, tenants, templates, usage, deliveries, … — not just the ones you can delete through the API. errors[] carries one { path: [table], message } per blocking table, e.g. { "path": ["tenants"], "message": "rows in tenants still reference this project" }. The probe is an EXISTS, so it names the tables that are non-empty and never how many rows are in them Delete or move the listed resources and retry. Tables you cannot empty through the API (messages, project_usage, daily_stats, message_status_events) mean the project has been used and is not deletable today — that was already true of messages before the other tables were added to the guard, so no project that used to delete cleanly stopped doing so.
PROJECT_IS_DEFAULT 409 DELETE /v1/projects/:id against the org's default project — the fallback every legacy org-scoped row routes through Default projects cannot be deleted. Delete a non-default project instead.
PROJECT_SLUG_TAKEN 409 POST /v1/projects or PATCH /v1/projects/:id with a slug another project in the org already holds Pick a different slug. Retrying the same one cannot succeed.

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.
SPEND_CAP_EXCEEDED (402) The send would push month-to-date metered spend past the org's spend cap. Metered tiers only; a send costing nothing (inside a plan allowance) is never refused, and the send landing exactly on the cap is the last one allowed. A message already scheduled when the cap starts binding is never refused or failed — the send cron defers it an hour at a time until the cap moves (see scheduled-sends.md) Raise or clear the cap in Settings → Billing, or wait for the period to roll. usage.alert webhooks fire at 80% and 100% before this does.
WARMUP_LIMIT_EXCEEDED Domain is in warmup window; today's cap reached Spread sends across days, or finish warmup.
SANDBOX_DAILY_LIMIT Live sends from your org's sandbox sender are capped at 100 recipients (to + cc + bcc) per org per UTC day, across every sandbox sender the org holds; dashboard template test sends count too. The message gives the reset time (the next 00:00 UTC); there is no Retry-After, and the SDKs do not retry it. Email codes from POST /v1/verifications are refused past the cap but do not count toward it. Per-item in POST /v1/emails/batch Wait for the day to roll over, or add and verify your own domain, which has no such cap.
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.
PROVISION_LIMIT_EXCEEDED Brand / campaign / number registrations, 10 per hour per org — each one costs real money at the registry. PATCH /v1/brands/:id shares the same bucket: it re-submits the registration already held, so it bills nothing, but it still spends registration versions and carrier review cycles Wait, or contact support for a higher cap. Test-mode provisioning has its own bucket, so CI cannot exhaust the live allowance.
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.

Codes the sections above do not list

Every code below is emitted by a /v1 route or by src/lib/api/* today (grep apiError( / validationError(), grouped by type. Same columns as the tables above.

authentication_error

Code HTTP Meaning Recover by
SENDER_DOMAIN_NOT_VERIFIED 403 Live email send from a domain this org has not verified (or has never added) Add + verify the domain via /v1/domains, then resend. Test keys skip this check.
SCOPE_ESCALATION 403 POST /v1/keys from a scoped key that omitted scopes (a scoped key cannot mint a full-access one) or asked for a scope it does not hold Grant a subset of the calling key's scopes.
ENVIRONMENT_ESCALATION 403 A sok_test_* key tried to act on live state. Two shapes: it tried to create, modify or revoke a live key (POST /v1/keys, PATCH/DELETE /v1/keys/{id}), or it called an operation that is live no matter which key asks because the row is shared between both modes — POST /v1/webhooks/{id}/rotate (that secret is the live signing secret), POST /v1/tenants/{id}/erase, DELETE /v1/tenants/{id} (archive) and POST /v1/tenants/{id}/suspend (all three take a live tenant offline and cancel its live scheduled backlog) Use a live key. Test keys stay unrestricted for sends, reads and their own test-mode resources.
DOMAIN_SCOPE_ESCALATION 403 Key create / patch asked for allowed_domain_ids wider than the calling key's, or left it unrestricted when the caller is restricted Pass a subset of the caller's allowed domains.
CIDR_SCOPE_ESCALATION 403 Same rule for allowed_cidrs Pass a subset of the caller's allowed CIDRs.
EXPIRY_ESCALATION 403 Key create from an expiring key that omitted expires_in_days or asked for an expiry beyond its own Pass an expiry no later than the calling key's.
RATE_LIMIT_ESCALATION 403 Key create / patch from a rate-limited key that omitted rate_limit_per_minute, cleared it, or asked for more than its own Pass a limit at or below the calling key's.
PROJECT_SCOPED_KEY 403 POST /v1/projects from a key bound to one project Use an account-wide key.
TENANT_KEY_FORBIDDEN 403 Two surfaces, same code: a tenant-bound key supplied X-Sendoka-Tenant-Ref (only a platform-root key may name a tenant), or it called a platform-only route — POST /tenants/:id/{suspend,unsuspend,restore,erase}, PATCH /tenants/:id/quota Drop the header, or use a platform-root key.
TENANT_SUSPENDED / TENANT_ARCHIVED / TENANT_NOT_FOUND 403 The tenant a tenant-bound key belongs to is not usable, so every request on that key is refused up front Unsuspend / restore the tenant with a platform-root key, or mint a key for a live tenant. (TENANT_NOT_FOUND also appears as a 422 validation_error below — different surface.)
TENANT_NOT_USABLE 409 X-Sendoka-Tenant-Ref resolved to an existing tenant that is suspended or archived; auto-resolution never resurrects one Unsuspend / restore it explicitly, or send under a different ref.

validation_error

Code HTTP Meaning Recover by
INVALID_FROM 422 Live email from has no @domain part Send from name@domain or Name <name@domain>.
HIGH_COST_DESTINATION 422 SMS to is in a satellite / premium range Sendoka does not serve (POST /v1/sms, /v1/verifications; per-item in /v1/sms/batch) Nothing — the range is refused in every environment.
MMS_TOO_MANY_ATTACHMENTS 422 More media_url entries than the MMS cap (per-item in batch) Send fewer attachments.
MMS_INVALID_MEDIA_URI 422 A media_url is not an s3://bucket/key URI — the provider accepts only S3 (per-item in batch) Upload to S3 and pass the s3:// URI.
SCHEDULED_SINGLE_RECIPIENT 422 scheduled_at on an email with more than one to — the stored envelope holds one recipient Use cc / bcc, one send per recipient, or send now.
SCHEDULED_NO_ATTACHMENTS 422 scheduled_at on an email carrying attachments — bytes are never stored Send at request time.
TEMPLATE_TENANT_SCOPED 422 The template slug exists only on individual tenants and the send carried no tenant Send with X-Sendoka-Tenant-Ref, or create an org-level template with that slug.
IP_POOL_PAUSED 409 The sending domain is assigned to a dedicated IP pool that is paused Resume the pool or unassign the domain.
NO_SENDER 422 POST /v1/verifications without from — codes go out from your own sender or not at all Pass a registered number / sender ID (SMS) or an address on a verified domain (email).
UNDELIVERABLE_DESTINATION 422 The verification destination cannot receive a code Correct the destination.
UNAVAILABLE 503 A verification could not be started, or POST /verifications/:id/check could not record the attempt — ours, and the code the user holds is still good Retry in a moment.
SEND_FAILED 502 POST /v1/verifications: the provider refused the code. Carries type: "validation_error" despite the 502 — a case where branching on type alone would misroute it Retry; check the destination if it persists.
BY_KEY_NOT_AVAILABLE 422 GET /v1/usage?by_key=true from a tenant-bound key — usage has no per-key breakdown at tenant scope Drop by_key, or use a platform-root key.
SELF_REVOKE_FORBIDDEN 422 DELETE /v1/keys/:id naming the key making the request Revoke it from the dashboard or with another key.
DOMAIN_ALREADY_ADDED 409 POST /v1/domains for a domain already in this project (names compare case-insensitively) Use the existing dom_ row.
DOMAIN_RESERVED 422 POST /v1/domains (and the dashboard add) for sendoka.com or any name under it, including any org's {org-slug}.sandbox.sendoka.com host. That zone is Sendoka's own sending identity. Refused before the plan cap and any provider call Add a domain you control. Your own sandbox sender is already listed under Domains.
DOMAIN_TAKEN_AT_PROVIDER 409 The email provider already holds this domain identity under another account Contact support; the identity has to be released first.
DOMAIN_LIVE_ONLY 422 Domain create / verify / delete with a sok_test_* key — domains have no simulated shape and test sends need none Use a live key.
TEMPLATE_SLUG_TAKEN / AUDIENCE_SLUG_TAKEN / POOL_SLUG_TAKEN / PROJECT_SLUG_TAKEN 409 Create or rename collided with an existing slug in the same org (+ tenant) Pick another slug.
AUDIENCE_SEND_IN_PROGRESS 409 DELETE /v1/audiences/:id while a send job on it is scheduled or running Wait, or DELETE /v1/jobs/:id first.
POOL_NUMBER_NOT_OWNED 422 A number in numbers is not provisioned + verified for this org (or is a test-mode number — pools have no environment) Provision and verify the number with a live key first.
NO_PROVIDER_NUMBER 422 PATCH /v1/phone-numbers/:id with an empty body (re-check) on a row that never got a provider id Nothing to re-check; provision again.
TENANT_NOT_FOUND 422 tenant_id in a brand / campaign / domain create is not a tenant of this org Use a ten_ id from GET /v1/tenants.
TENANT_EXTERNAL_REF_CONFLICT 409 external_ref already belongs to another tenant Reuse that tenant, or pick another ref.
TENANT_NOT_ACTIVE 409 DELETE /v1/tenants/:id (archive) on a tenant that is not active Only active tenants archive.
TENANT_ARCHIVED 409 POST /v1/tenants/:id/suspend on an archived tenant Restore first, then suspend.
TENANT_NOT_SUSPENDED 409 POST /v1/tenants/:id/unsuspend on a tenant that is not suspended (including one that changed state mid-request) Read the tenant's status and act on it.
TENANT_NOT_ARCHIVED 409 POST /v1/tenants/:id/restore on a suspended (not archived) tenant Unsuspend instead. Restoring an already-active tenant is a 200 with restored: false.
PROJECT_IS_DEFAULT 409 DELETE /v1/projects/:id on the account's default project It cannot be deleted.
PROJECT_NOT_EMPTY 409 DELETE /v1/projects/:id while resources still reference it; the message lists what Move or delete them first.
CAMPAIGN_IS_LIVE_MODE / PHONE_NUMBER_IS_LIVE_MODE 422 Same rule as BRAND_IS_LIVE_MODE for campaigns and numbers: a test key may only touch a resource carrying a simulated test-reg-… id Use a live key.

rate_limit_error

Code HTTP Meaning Recover by
TENANT_QUOTA_EXCEEDED 429 The tenant's own monthly quota (PATCH /v1/tenants/:id/quota) is used up Raise the tenant's quota or wait for the period.
PLAN_RESOURCE_LIMIT 402 The plan's cap on a countable resource — sending domains, webhook endpoints, tenants, dedicated IP pools, API keys, team seats — is reached; the message names which. Webhook endpoints are capped per scope — org-level endpoints against one copy of the cap, each tenant's against its own — and the message names the scope that is full (organization-level webhook endpoints, or webhook endpoints for tenant ten_…) Upgrade, or delete one in the scope the message names.
DEDICATED_IP_LIMIT_EXCEEDED 402 Requested dedicated IPs would exceed the plan's cap Upgrade, or release IPs.
KEY_CREATE_RATE_LIMITED 429 More than 10 POST /v1/keys in an hour Wait.
TENANT_AUTO_CREATE_RATE_LIMITED 429 Too many never-seen X-Sendoka-Tenant-Ref values created tenants in the window Pre-create tenants with POST /v1/tenants, or slow the rollout.
PROVIDER_QUOTA_EXCEEDED 429 An audience blast would push the SES account past its 24h send quota; the message carries the numbers Wait for the window, split the blast, or request a quota increase.

server_error

Code HTTP Meaning Recover by
PROVIDER_UNAVAILABLE 502 A domain create / re-verify or a number re-check could not reach the provider Retry with backoff.
PROVIDER_EMPTY 502 The provider leased a number but returned no E.164 for it; the lease is released and nothing is stored Retry; report if it persists.
AUDIENCE_SEND_INCOMPLETE 500 POST /v1/audiences/{id}/send: the job was created but scheduling its messages failed part-way, or the request was cut off before it recorded how it ended (the replay of a provisional record written with the job). error.job_id names it; rows that landed will send unless the job is canceled. Recorded under the Idempotency-Key, so a retry with the same key replays this answer rather than blasting again — the SDKs do not retry it. Without a key nothing is recorded, and a retry is a whole second blast GET /v1/jobs/{job_id} to see how many landed, DELETE it to stop them, then send under a new key.

Per-item codes inside a batch response

These appear in data[].error.code of /v1/emails/batch and /v1/sms/batch, never as a top-level error.code. The HTTP status of the batch itself is 200.

Code Meaning
PROVIDER_ERROR The provider refused this item. With a non-null id the failure row is stored and message.failed fires; with id: null the row could not be stored either — the response is the only trace, and retrying the item is safe.
MESSAGE_NOT_RECORDED The provider accepted the item (it was delivered and is billed) but the message row could not be stored. status: "sent", id: null. Do not retry.
VALIDATION_ERROR, SUPPRESSED, SENDER_NOT_VERIFIED, HIGH_COST_DESTINATION, SMS_DESTINATION_NOT_ALLOWED, MMS_*, DLT_*, UNSUBSCRIBE_*, WARMUP_LIMIT_EXCEEDED, SANDBOX_RECIPIENT_NOT_ALLOWED, SANDBOX_DAILY_LIMIT Rejected before any send attempt — id: null, nothing fanned out. Same meaning as the top-level code of the same name.

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 under the same key — on an audience send the blast is still scheduling and can take minutes, so retry later to get its job_id.
409 IDEMPOTENCY_MISMATCH Generate a fresh UUID key.
500 AUDIENCE_SEND_INCOMPLETE Check GET /v1/jobs/{job_id}, cancel it if needed, then send under a new key. Never just retry under a new key.
422 VALIDATION_ERROR Inspect errors[]; fix fields; do not retry blindly.
422 TEMPLATE_MISSING_VARIABLES Include the missing {{var}} in variables. For an audience blast, no recipient had the key: fix the typo or pass it in variables.
422 DLT_TEMPLATE_* Register + approve a DLT template, then resend with dlt_template_id (India DLT). Retrying unchanged never succeeds.
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.