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. At most 20 rows are returned; fix those and re-submit to see any others. A batch array over its cap (100 items on /v1/emails/batch and /v1/sms/batch, including a bulk-shape to; 1,000 contacts on POST /v1/audiences/{id}/contacts) is refused on its length alone, before any item is validated, with a single too_big row.

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, test-mode queue cap and daily allowance), 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 — or X-Sendoka-Tenant-Ref matched no tenant and the key lacks write:tenants to auto-create one 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, …) are resolved to one country by the national prefix, and that country must be listed: ["US"] does not reach Puerto Rico, the US Virgin Islands, Guam, American Samoa or the Northern Mariana Islands (PR, VI, GU, AS, MP), nor Canada or the Caribbean. A +1 toll-free number passes only when every +1 country is listed. See SMS destination permissions.
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. A key bound to one project is also refused a domain another project holds, and when the project is the only reason (the key's tenant binding and allowed_domain_ids would pass the domain's row) the message says the domain belongs to another project, without naming it: send with a key of that project or of the default project 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.
UNSUBSCRIBE_TOKEN_LIMIT 422 {{unsubscribe_url}} appears more than 10 times in one part of the message (subject, HTML or text). Each copy becomes the recipient's signed link, several hundred characters long, so the count is bounded before substitution. Counted after a stored template renders, so a {{#each}} or a variable that repeats the token counts every copy. POST /v1/emails; per item in POST /v1/emails/batch; POST /v1/audiences/{id}/send refuses the whole blast and schedules nothing Keep one or two unsubscribe links per part; look for a loop or a variable that repeats the token.
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 attachment size > 7 MB, inline and url together (headroom under SES's 10 MB raw cap). url bytes are only measured once fetched. Split across emails, or link to the file instead of attaching it.
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 or takes more than 5,000,000 units of work (each text run, placeholder, block and iteration, each scope a name is looked up in and key a path walks, an object's keys the first time {{#if}} asks of it, and a value's length; see templates); 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, the whole blast passes 500,000,000, its renders together (click tracking's work on each link included) pass 250,000,000 units, its distinct variables times its recipients pass 10,000,000 checks, or those checks pass 80,000,000 units; the message names the contact where there is one, 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; IN with dlt_header_id).
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.
SENDER_CAMPAIGN_NOT_APPROVED 422 Live SMS send from a US long code or toll-free number whose carrier registration is not approved: no bound campaign, a campaign of the wrong kind, one still pending, one registered with a test key, or a binding with no association on record at the registry. AWS reports such numbers ACTIVE, and the carrier blocks their traffic. Only refused while the platform runs with SMS_CARRIER_GATE=enforce; until then the send goes out and the would-be refusal is logged A long code: lease it under an approved 10DLC campaign, or — if it is already bound but has no association on record — PATCH its campaign_id with the same campaign to associate it. A toll-free number: file a kind: "tollfree" campaign with phone_number_id and wait for it to verify.
SENDER_ID_REJECTED 422 kind: "alphanumeric" registration the provider refused — the country does not support sender IDs, or requires a sender-ID registration the account does not hold. The message carries AWS's reason; nothing is stored Complete the country's sender-ID registration, or use a number.
CAMPAIGN_REQUIRED 422 US longcode provision with no campaign_id. A US long code is a 10DLC number and is leased under its campaign Pass the cmp_ id of a verified 10dlc campaign.
CAMPAIGN_KIND_MISMATCH 422 A US number bound to a campaign of the wrong kind (a long code to a toll-free verification, or the reverse) Use a 10dlc campaign for a long code; a toll-free number is bound by its own verification.
CAMPAIGN_NOT_REGISTERED 422 A live US long code requested under a campaign that holds no registry registration id Wait for the campaign's registration (or the reconcile cron) to land.
TOLLFREE_CAMPAIGN_BINDS_NUMBER 422 campaign_id given for a US toll-free number on PATCH, or on the dashboard's provision. (The v1 provision accepts it as the legacy campaign-first order.) A toll-free verification is filed for the number Provision without campaign_id, then POST /v1/campaigns with kind: "tollfree" and phone_number_id.
CAMPAIGN_IS_LIVE_MODE 422 A test key requested a toll-free number under a campaign created with a live key (legacy order) — a simulated filing would mark the live campaign verified Use a toll-free campaign created with a test key.
NUMBER_CAMPAIGN_LOCKED 409 PATCH /v1/phone-numbers/:id moving or unbinding a US long code whose association with a 10DLC campaign is on record — the registry cannot disassociate it Release the number and lease a new one under the other campaign.
ASSOCIATION_REJECTED 422 The registry refused to associate a number with a campaign on PATCH — most often a pre-10DLC long code Lease a new number under the campaign.
ASSOCIATION_CONFLICT 409 PATCH /v1/phone-numbers/:id binding a US long code the registry already holds under a different registration (or one it could not confirm) Release the number and lease a new one under the campaign.
PHONE_NUMBER_NOT_FOUND / PHONE_NUMBER_NOT_TOLLFREE / PHONE_NUMBER_IS_TEST_MODE / PHONE_NUMBER_NOT_PROVISIONED 422 Toll-free campaign create whose phone_number_id is unknown, not a toll-free number, a test key's simulated number (live key), or has no provider id Name a live toll-free pho_ number of this org.
PHONE_NUMBER_IS_LIVE_MODE 422 Toll-free campaign create with a test key whose phone_number_id is a live number A test key files verifications only for numbers a test key provisioned.
PHONE_NUMBER_ALREADY_BOUND 409 Toll-free campaign create for a number that already has a verification — including one bound by a concurrent create while this one was being filed (that create's registration and campaign are then discarded) One verification per toll-free number; delete the old campaign first.
DLT_HEADER_NOT_FOUND / DLT_HEADER_NOT_VERIFIED / DLT_ENTITY_NOT_VERIFIED / DLT_HEADER_MISMATCH 422 India sender-ID registration whose dlt_header_id is unknown, not verified, under an entity without a verified PEID, or not the sender_id given Register and verify the header and entity at /overview/dlt; pass the header itself as sender_id.
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_ID_UNAVAILABLE 409 kind: "alphanumeric" registration of a sender_id another organization already holds, or one that reads the same (compared ignoring case, separators and look-alike digits: acme, A.C.M.E and ACM3 are all ACME). A sender ID is the name a handset shows as the author, so one org's ID is never registered to another. A row another org registered with a sok_test_ key does not hold it. Refused before the provider is asked, for test keys too Choose a different sender ID.
SENDER_ID_RESERVED 422 kind: "alphanumeric" registration of a sender_id that names a bank, card network, carrier, government body, major consumer brand or Sendoka (also when it contains the name, e.g. HSBCALERTS). Refused before the provider is asked, for test keys too Registering it needs manual approval: contact support@sendoka.com from the organization that owns the name.
SENDER_ALREADY_REGISTERED 409 Tried to register an alphanumeric sender_id that's already on the org. A live registration of an ID only a sok_test_ key registered is not refused: it replaces the test-mode row 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 an address that is not global unicast (private, loopback, metadata, reserved, or an IPv6 form wrapping one), 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. Test keys are rated in a per-minute window of their own, the same size as the plan's, so test traffic never throttles live requests (rate-limits.md) 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; the cap for the day the send fires is reached (today, or the scheduled day for a scheduled send or audience blast) 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), counted separately for live and test keys. Wait.
TEST_FIRE_RATE_LIMITED Too many synthetic events triggered 20/min per endpoint, counted separately for live and test keys. Wait.
TEST_SCHEDULE_LIMIT_EXCEEDED Test keys only. A scheduled POST /v1/emails or /v1/sms, a POST /v1/audiences/{id}/send, or a dashboard resend of a test message would take the organization past 20,000 test-mode messages waiting to send (scheduled, or mid-send) — or, in a tenant's scope, that tenant past 5,000 (a quarter). Test sends share the scheduled queue with live ones; nothing is scheduled. A tenant-scoped caller is never told the organization's count. No Retry-After; the SDKs do not retry it Wait for them to send, or cancel some (DELETE /v1/jobs/{id} stops a blast), or schedule fewer. Live keys are never limited by it.
TEST_VOLUME_LIMIT_EXCEEDED Test keys only. Test mode's daily allowance of messages is spent, or this request would pass it: 1,000 test-mode messages per organization per UTC day on Free, 10,000 on Pay as you go and Pro, 100,000 on Enterprise, and in a tenant's scope a quarter of that for the tenant as well. Every test-mode message row counts — POST /v1/emails and /v1/sms (scheduled or not), every item of POST /v1/emails/batch and /v1/sms/batch (the bulk shape per recipient), every recipient of POST /v1/audiences/{id}/send, a test-mode POST /v1/verifications, and a dashboard resend or template test send. A request is charged whole or refused whole; nothing was written. The message gives the reset instant (the next 00:00 UTC) and, to an organization-level caller, what is left; a tenant-scoped caller hears only its own share's numbers. No Retry-After; the SDKs do not retry it (rate-limits.md) Wait for 00:00 UTC, or send fewer. Live keys are never limited by it.
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. Also any /api/v1/* path that matches no endpoint (a typo, a removed route): the body is the same JSON envelope, with no auth check, never an HTML page.
ATTACHMENT_NOT_STORED GET /v1/inbound/:id/attachments/:index for an attachment that is listed but whose contents were not kept — past the 50-per-message cap, a storage failure at receipt, or a message received before attachments were stored. The metadata says so in advance: downloadable: false.

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 any key, or modify or revoke a live key (POST /v1/keys refuses a test key outright: every key minted spends the org's key-creation limit and a slot of the plan's key cap, both shared with live; PATCH/DELETE /v1/keys/{id} on a live key), or it tried to write something both modes share, so the write is live whichever key makes it. Webhook endpoints: POST /v1/webhooks, PATCH/DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/rotate (every endpoint receives live events, and its secret is the live signing secret). Templates: POST /v1/templates, PATCH/DELETE /v1/templates/{id} (a tenant's template takes precedence over the org's of the same slug, so even a new one can replace live mail). Suppressions: POST/DELETE /v1/suppressions. Messaging pools: POST /v1/messaging-pools, PATCH/DELETE /v1/messaging-pools/{id} (a tenant's pool takes precedence the same way). Audiences and contacts: POST /v1/audiences, PATCH/DELETE /v1/audiences/{id}, POST /v1/audiences/{id}/contacts, POST /v1/contacts, PATCH/DELETE /v1/contacts/{id} (a live blast mails whoever is on the list). Tenants: POST /v1/tenants, DELETE /v1/tenants/{id} (archive), and POST /v1/tenants/{id}/erase, /suspend, /unsuspend, /restore, PATCH /v1/tenants/{id}/quota; and an X-Sendoka-Tenant-Ref that matches no tenant, which a live key would auto-create Use a live key; a scoped one is enough. A test key keeps every send (including POST /v1/audiences/{id}/send to an existing audience), every read, simulated 10DLC provisioning, and changing or revoking existing test keys; create keys in the dashboard or with a live key. To test against shared fixtures, create them once with a live key: Running CI with a test key.
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, POST /projects, PATCH/DELETE /projects/:id 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.
TENANT_OTHER_PROJECT 403 X-Sendoka-Tenant-Ref names a tenant that belongs to another project of this organization than the calling key's. Refs are unique per organization, so no tenant was created either. Never returned to a key with no project or in the default project, or for a tenant in the default project Send the header with a key of the tenant's project (or of the default project).

validation_error

Code HTTP Meaning Recover by
INVALID_FROM 422 Live email from has no @domain part, or has more than one @ (a quoted local part like "a@b"@domain). On POST /v1/verifications also returned in any environment for a malformed email from or a quoted local part, before anything is charged 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.
ATTACHMENT_FETCH_FAILED 422 POST /v1/emails: an attachment url was refused at send time — a 4xx answer (other than 429), a redirect, an empty body, a private or internal address, not https, or one url over 5 MB. A failed message row is stored. A host-side failure (5xx, 429, timeout, unresolvable host) keeps the generic retryable 500, so a retry can succeed once the host recovers. Until 2026-11 this was a generic 500 the SDKs retried. In a batch the item fails as PROVIDER_ERROR with the same message Fix or re-host the url, or send the bytes inline as content.
MMS_NOT_SUPPORTED 422 POST /v1/sms: media_url to a +91 recipient while India DLT enforcement is on. A failed message row is stored. Until 2026-11 this was a generic 500. In a batch the item fails as PROVIDER_ERROR Send text only to India.
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_PLATFORM_MANAGED 409 DELETE /v1/domains/:id, POST /v1/domains/:id/verify or POST /v1/domains/:id/inbound (and the dashboard's Remove / Re-check / Receive mail) on your organization's {org-slug}.sandbox.sendoka.com sender. Sendoka operates it: it is always verified, nothing could re-create it once removed, and it has no provider identity of its own to re-check Nothing to do. Add your own domain to send to anyone beyond your team.
DOMAIN_CLAIMED_BY_ANOTHER_ACCOUNT 409 POST /v1/domains (and the dashboard add) for a name another Sendoka account already holds (any status), or any name beneath one it has verified — x.acme.com when another account has verified acme.com. Another account's pending parent does not block. Matched on label boundaries, so notacme.com is unaffected. Refused before any provider call Contact support if you own the domain. Subdomains of a domain your own account holds are fine.
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_NOT_VERIFIED 409 POST /v1/domains/:id/inbound on a domain still pending — inbound routing only delivers to verified domains Publish the DKIM records and verify the domain, then enable inbound.
INBOUND_REGION_IDENTITY_MISSING 409 POST /v1/domains/:id/inbound: mail is received in one AWS region for every domain, and this domain's identity is not verified in it. The provider would reject its mail at the MX Contact support.
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 (or the dashboard's delete) while a send job on it is scheduled or running Wait, or DELETE /v1/jobs/:id first.
AUDIENCE_NOT_FOUND 422 A slug in audiences on POST /v1/contacts or PATCH /v1/contacts/:id names no audience the key can reach. The message lists them. Nothing was written Create the audience first, or fix the slug.
AUDIENCE_SLUG_AMBIGUOUS 422 A root key's slug in audiences names audiences of more than one tenant, and no org-level audience has it. Nothing was written Use the tenant's key, or POST /v1/audiences/:id/contacts with the audience's id.
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 create (brand, campaign, domain, phone number or sender ID) is not a tenant of this org — or, for a key bound to a project, is a tenant of another project the key cannot reach Use a ten_ id from GET /v1/tenants, or a key of the tenant's project.
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, dedicated IPs, phone numbers, 10DLC brands and campaigns, API keys, team seats — is reached; the message names which. Phone numbers (Free 1 / Pay as you go 10 / Pro 25) count live leased numbers only — not alphanumeric sender IDs, not test-key numbers, and a test key is not held to the cap. Brands and 10DLC campaigns are capped on Free only (1 each per org, not per tenant), over live registrations — not test-key ones, not a failed campaign or a failed brand that never reached the registry. A toll-free verification is not capped, but a brand created to back one counts as a brand. Free also caps creations: 3 live number leases, 3 live brand filings and 5 live 10DLC campaign filings per rolling 30 days, released or deleted ones included (Free plan allows 3 new phone numbers per 30 days, released ones included (3/3 used)). Dedicated IPs (Free 0 / Pay as you go 2 / Pro 5) count ip_count across pools, and a pool create must fit whole (…; this request adds 3). 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 operator ceiling (IP_POOL_MAX_IPS_PER_ORG, unset by default; the plan's own cap answers PLAN_RESOURCE_LIMIT) Release IPs, or contact support.
KEY_CREATE_RATE_LIMITED 429 More than 10 keys created in an hour, through POST /v1/keys and the dashboard together (test keys cannot create keys, so they never spend it) 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, a number or pending sender-ID re-check, a sender-ID lease, a legacy-order toll-free lease or a number-to-campaign association could not reach the provider — or our own credentials were refused it (an IAM denial, which is ours to fix, never a refusal of your request) Retry with backoff.
CAMPAIGN_REGISTRATION_BUSY 503 POST /v1/phone-numbers with a toll-free campaign_id (legacy order) while another request, or the reconcile cron, is leasing that campaign's number and has not bound it yet. Sent with Retry-After: 30 Retry after the delay; the retry gets the number the other caller bound.
INBOUND_NOT_CONFIGURED 503 POST / DELETE /v1/domains/:id/inbound on a deployment where inbound email has not been set up (no receiving bucket, rule set or topic). GET answers available: false instead Contact support.
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. The message is a fixed sentence (see send-failure text), never the provider's own exception text.
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.

Send-failure text

When a send fails at the provider or inside Sendoka, the reason stored in error_message (on GET /v1/emails/{id} and GET /v1/sms/{id}), on the failed entry of status_history[], in a message.failed webhook's data.error, in a batch item's error.message, in a verification's delivery_error, and in a dashboard template test-send's error is one of a fixed set of sentences, never the raw provider or database exception, which can name internal account details. Examples:

  • Email address is not verified. The following identities failed the check: … (the identities are your own from address or domain, or the recipient)
  • The provider could not use the domain's custom MAIL FROM setup
  • Provider rate exceeded; safe to retry / Provider sending quota reached
  • The SMS provider rejected the message (DESTINATION_PHONE_NUMBER_OPTED_OUT) (the bracketed reason is the provider's documented reason code)
  • Attachment url could not be fetched: fetch failed 404
  • The message could not be sent because of an internal error

Refusals Sendoka raises itself (a paused IP pool, the India DLT guard, an oversized attachment) keep their own wording. Bounce and complaint text from delivery callbacks (Bounce: Permanent (General) — …) is unchanged.

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: 1713820800000
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.