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 oncodeand the status, not ontype.code— stable identifier for programmatic handling; see catalog.message— human string; may reference the first field issue.request_id— injected bywithApiAuthon every/v1error response; also emitted as theX-Request-Idheader. Include in support tickets.errors[]— present on validation errors from a Zod schema. Each row haspath(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/batchand/v1/sms/batch, including a bulk-shapeto; 1,000contactsonPOST /v1/audiences/{id}/contacts) is refused on its length alone, before any item is validated, with a singletoo_bigrow.
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 setupProvider rate exceeded; safe to retry/Provider sending quota reachedThe 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 404The 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. |