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.
Types
type says which kind of thing went wrong; the HTTP status says whether it
is worth retrying. The two are set independently, so the same type is seen
under several statuses:
type |
What it means | Statuses it is emitted under |
|---|---|---|
authentication_error |
The credential, its scope, its restrictions, or the org/tenant it belongs to refused the request. Retrying unchanged never succeeds | 401 (no usable key), 403 (key lacks scope / IP not allowed / escalation refused / org suspended / tenant unusable / unverified sender domain / sandbox sender recipient / SMS destination not allowed), 409 (TENANT_NOT_USABLE — the auto-resolved tenant is suspended or archived) |
validation_error |
The request body, a query param, or the current state of the resource makes the operation impossible as asked. Fix the input or the state, then retry | 422 (input), 409 (state conflict — idempotency mismatch, slug taken, not scheduled, tenant lifecycle), 413 (payload / attachment / audience too large), 502 (SEND_FAILED — the provider refused the verification code; typed validation_error even though the fault is downstream), 503 (UNAVAILABLE — a verification could not be started or its check recorded; ours, retry) |
rate_limit_error |
A limit — burst, monthly quota, spend cap, plan resource cap, provisioning or replay budget, in-flight lock — refused the request. Retry after the window or raise the cap | 429 (burst, quota, provisioning, replay, key-create, tenant auto-create, sandbox daily cap), 402 (SPEND_CAP_EXCEEDED, PLAN_RESOURCE_LIMIT, DEDICATED_IP_LIMIT_EXCEEDED — a paid cap, not a window), 409 (IDEMPOTENCY_IN_FLIGHT — the same key is still running) |
not_found_error |
The id does not exist in your org / project / tenant / environment. A row another key can see is still a 404 to you | 404 |
server_error |
Ours, or the provider's. Retry with backoff and quote request_id if it persists |
500 (unhandled), 502 (PROVIDER_UNAVAILABLE, PROVIDER_EMPTY) |
Full code catalog
Authentication — 401 / 403
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
UNAUTHORIZED |
401 | Missing Authorization header or invalid/revoked/expired key |
Check Authorization: Bearer sok_live_.... Rotate if compromised. |
INSUFFICIENT_SCOPE |
403 | Key doesn't include the scope required for this endpoint | Recreate the key with the listed scope (authentication.md). |
IP_NOT_ALLOWED |
403 | Request IP is outside the key's allowed_cidrs list |
Add the caller IP / CIDR to the key, or send from an allowed network. |
TENANT_FORBIDDEN |
403 | Tenant-bound key tried to act on a different tenant's resource | Use a platform-root key, or the correct tenant's key. |
TENANT_KEY_CANNOT_CREATE_TENANTS |
403 | Tenant-bound key attempted POST /tenants |
Use a platform-root key. |
TENANT_KEY_CANNOT_DELETE |
403 | Tenant-bound key attempted DELETE /tenants/:id |
Use a platform-root key. |
DOMAIN_TENANT_MISMATCH |
403 | from address belongs to a different tenant |
Send from a domain owned by the key's tenant (or a null-tenant shared domain). |
DOMAIN_NOT_ALLOWED_FOR_KEY |
403 | from domain isn't in the key's allowed_domain_ids[] |
Send from an allowed domain, or add this domain to the key's allowlist. |
ORG_SUSPENDED |
403 | The organization has been suspended by an operator. Every /v1 request is refused; the dashboard stays reachable so an owner can see why |
Contact support@sendoka.com. Nothing in the API clears this — an operator lifts it. |
SMS_DESTINATION_NOT_ALLOWED |
403 | The destination's country isn't on the org's allowed SMS destinations list. Surfaces on POST /v1/sms, POST /v1/verifications (SMS channel), per-item in POST /v1/sms/batch, and on POST /v1/audiences/{id}/send (SMS channel), where one off-list recipient refuses the whole blast |
An owner edits the list in Settings → SMS destinations. Shared dial codes (+1, +44, …) pass if any country sharing the prefix is allowed. |
SENDER_DOMAIN_NOT_VERIFIED |
403 | The from address's domain is not a verified domain of this org — either it was never added, or it is still pending DNS. Live email sends only; test-mode sends skip the check |
Add and verify it via POST /v1/domains and publish the DNS records, or send from your org's sandbox sender (which only reaches your own members — see SANDBOX_RECIPIENT_NOT_ALLOWED). That hostname is per-org ({org-slug}.sandbox.sendoka.com, not sandbox.sendoka.com) — copy the exact one from Overview → Domains. |
SANDBOX_RECIPIENT_NOT_ALLOWED |
403 | Live email from your org's sandbox sender ({org-slug}.sandbox.sendoka.com) to an address that is not the verified sign-in address of a member of your org. Every to, cc and bcc is judged: after suppression on POST /v1/emails, per item on the full list in POST /v1/emails/batch. When a refused address belongs to a member who has not verified it yet, the message says so. Also refuses any audience broadcast from the sandbox sender outright, whoever is in the audience. Surfaces on POST /v1/emails (immediate and scheduled), per-item in POST /v1/emails/batch, POST /v1/audiences/{id}/send, email POST /v1/verifications and the dashboard send surfaces; a scheduled send that fails it at fire time is canceled with this message. Test keys are exempt |
Send to a member's verified sign-in address, or add and verify your own domain via POST /v1/domains and send from that to reach anyone else. A member whose own address is refused as not verified yet verifies it in the dashboard under Settings → Your sign-in email → Send verification link (the session route POST /api/auth/resend-verification). |
Validation — 409 / 413 / 422
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
VALIDATION_ERROR |
422 | Zod schema failed. Check error.errors[] for per-field issues. |
|
INVALID_CURSOR |
422 | cursor isn't a base64url({ISO}|{id}) value minted by a previous page |
Pass next_cursor from the previous response verbatim, or omit it to start over. |
INVALID_TENANT_REF |
422 | X-Sendoka-Tenant-Ref must be a non-empty string of ≤ 200 characters |
Send a valid external ref (the shop domain, typically). |
INVALID_EXTERNAL_REF |
422 | GET /v1/tenants?external_ref= was empty, only whitespace, or over 200 characters. It 422s rather than being dropped — a silently ignored filter returns the unfiltered first page, which is how a duplicate-tenant guard can run for months while every re-provision splits one shop across two tenants |
Send the ref you stored on the tenant; check the variable holding it isn't unset or blank. |
IDEMPOTENCY_MISMATCH |
409 | Idempotency-Key reused with a different request body |
Use a fresh key, or submit the byte-identical body. |
IDEMPOTENCY_IN_FLIGHT |
409 | Another request with this key is still running | Retry after a few hundred ms. The lock's TTL is 330s, but it is released as soon as the response is recorded, so on the send routes the window is effectively a few hundred ms. On POST /v1/audiences/{id}/send it is held for the whole blast, which can take minutes — retry later under the same key to get the job_id. A lock whose response failed to record is kept for its full TTL rather than freed with nothing replayable under it. An audience send cut off mid-blast answers this for 330s after its job was created, then replays the job as AUDIENCE_SEND_INCOMPLETE. In production an unreachable Redis also answers this code, since the lock fails closed. |
ALL_SUPPRESSED |
422 | Every recipient is on the suppression list (tenant + platform scopes). Which rows count depends on the send's stream — a transactional email is blocked by all rows only, a broadcast one by all plus one-click unsubscribes |
Scrub the list, or remove from suppressions if deliberate. Check you meant the stream you sent. |
TO_ALL_SUPPRESSED |
422 | Some recipients survived the filter, but every address in to was suppressed — so the send would deliver only to cc/bcc |
Drop the suppressed to entries, or move a live address into to. |
SUPPRESSED |
422 | Single SMS recipient (or batch item recipient) is suppressed. Per-item in a batch: the rest of the batch still sends | Scrub that recipient, or remove the suppression if deliberate. |
UNSUBSCRIBE_REQUIRES_BROADCAST |
422 | {{unsubscribe_url}} appeared in html / text on a send left on the default stream: "transactional". A body carrying an unsubscribe link is list mail, and a transactional send never reads the broadcast suppressions such a link writes — so honouring it would be a promise the next send breaks |
Set "stream": "broadcast" on the send (or the batch item), or drop the token. |
UNSUBSCRIBE_MULTI_RECIPIENT |
422 | {{unsubscribe_url}} with more than one destination across to + cc + bcc. The token binds to a single address, so on a shared envelope one recipient's click would unsubscribe to[0] |
Split into one send per recipient, or use the bulk shape of /v1/emails/batch, which fans one body out to one envelope each. |
NOT_SCHEDULED |
409 | Tried to DELETE a message that isn't in scheduled state. |
|
SCHEDULED_AT_IN_PAST |
422 | scheduled_at is more than 5 minutes before server time. The grace window is deliberate clock-skew tolerance — inside it the send is accepted and goes out immediately |
Send a future timestamp, or omit scheduled_at to send now. |
SCHEDULED_AT_TOO_FAR |
422 | scheduled_at is more than 366 days ahead |
Schedule within a year, or store the intent on your side and call us closer to the time. |
ATTACHMENT_TOO_LARGE |
413 | Combined inline attachment size > 7 MB (headroom under SES's 10 MB raw cap) | Use url attachments instead of inline base64, or split. |
PAYLOAD_TOO_LARGE |
413 | Request body > 6 MB single-send / 10 MB batch / 10 MB inbound webhook. Also a templated send that passes a render cap (POST /v1/emails, POST /v1/sms, audience sends): its {{#each}} blocks run more than 5,000 iterations in total or render more than 2,000,000 characters, or one part renders more than 10,000,000 characters; it is refused, never sent cut short. And POST /v1/audiences/{id}/send when one recipient's stored message (subject + HTML + text after tracking, plus headers) passes 900,000 bytes, or the whole blast passes 500,000,000; the message names the contact and nothing is scheduled |
Split the request, or use url attachments. For a template, shrink the loop body or the arrays its {{#each}} blocks loop over. For a blast past the total, split the audience. |
TEMPLATE_MISSING_VARIABLES |
422 | Template references {{var}} but variables didn't supply them. On POST /v1/audiences/{id}/send it is checked against every recipient after the per-contact merge, and only a placeholder that resolves for no recipient refuses the blast (not yet for a tenant-scoped key, whose blast renders it empty as before); one that some contacts lack renders empty for them |
Include the missing keys, or omit the template placeholders. For a blast, fix the typo, or pass a value in variables (a contact's metadata still overrides it). |
AUDIENCE_TOO_LARGE |
413 | Audience > 10 000 recipients in a single send call |
Split the audience. |
TENANT_SLUG_CONFLICT |
409 | Tenant slug already in use in this org. | |
TENANT_ACTIVE |
409 | POST /v1/tenants/:id/erase against a tenant that is still active. Erasure is irreversible, and an active tenant is one somebody is still sending for — refusing is the guard |
Archive or suspend the tenant first, then erase. |
SENDER_NOT_REGISTERED |
422 | Live SMS send: the from value has no matching phone_numbers row (looked up by e164 for E.164, sender_id for alphanumeric) |
Provision a number via /v1/phone-numbers (US/CA) or register an alphanumeric sender via kind: "alphanumeric" (UK/AU/EU). |
SENDER_NOT_VERIFIED |
422 | The phone_numbers row exists but status != verified |
Wait for the AWS verification poll (5 min) or PATCH with empty body to re-check. Alphanumeric senders verify instantly. |
COUNTRY_NOT_PROVISIONABLE |
422 | kind: "number" provision with an iso_country AWS doesn't allocate via API (anything outside US/CA) |
Use kind: "alphanumeric" for UK/AU/EU, or port a number in via your AWS support contact. |
SENDER_ALREADY_REGISTERED |
409 | Tried to register an alphanumeric sender_id that's already on the org |
Pick a different sender ID or DELETE the existing one. |
BRAND_NOT_FOUND |
422 | brand_id is unknown, or belongs to another org / project |
Use a brd_ id from GET /v1/brands. |
BRAND_NOT_VERIFIED |
422 | A 10DLC campaign was created against a brand the registry has not approved yet | Poll GET /v1/brands/:id until status: "verified". Toll-free campaigns do not reference a brand. |
BRAND_IS_TEST_MODE |
422 | Live campaign create against a brand registered with a sok_test_ key — it carries a simulated test-reg-… id and no registry registration |
Register the brand with a live key. Test-mode resources cannot back live ones. |
BRAND_IS_LIVE_MODE |
422 | PATCH /v1/brands/:id with a sok_test_ key against a brand a test key did not create. A test key may only correct a brand carrying a simulated test-reg-… id; every other brand is refused, including one with no registration id at all — a test-mode create always leaves a test-reg-… id behind, so a null id can only have come from a live create whose provider call failed |
Use a live key to correct a brand that was registered with one. |
BRAND_HAS_CAMPAIGNS |
409 | DELETE /v1/brands/:id while a campaign still references it |
Delete the campaigns first. The guard is global — a campaign in any project blocks it. |
CAMPAIGN_NOT_FOUND |
422 | campaign_id is unknown, or belongs to another org / project. |
|
CAMPAIGN_NOT_VERIFIED |
422 | Bound a number to a campaign the registry has not approved yet | Poll GET /v1/campaigns/:id until status: "verified". |
CAMPAIGN_IS_TEST_MODE |
422 | Live number binding against a campaign registered with a sok_test_ key |
Register the campaign with a live key. Applies on provision and on PATCH /v1/phone-numbers/:id. |
NUMBER_ALREADY_REGISTERED |
409 | The provisioned E.164 is already on this org | Only reachable in test mode, where numbers are drawn from a reserved range; retry. |
NO_FIELDS |
422 | PATCH /v1/brands/:id with a body carrying no updatable field |
Send at least one field from the UpdateBrand schema. |
WEBHOOK_URL_NOT_ALLOWED |
422 | Webhook URL resolves to a private / loopback / metadata IP, or uses a blocked protocol | Point the endpoint at a publicly reachable HTTPS URL. |
DLT_TEMPLATE_REQUIRED |
422 | "Sending to +91 (India) requires dlt_template_id referencing an approved DLT template." India DLT enforcement is on by default and applies in test mode as well as live. Reachable from POST /v1/verifications as well as the SMS routes — a +91 OTP needs its own registered template |
Pass dlt_template_id; register templates at /overview/dlt. See sms.md. |
DLT_TEMPLATE_NOT_FOUND |
422 | "dlt_template_id '<id>' not found for this org." | Use a dlt_ id from this org's template list. |
DLT_TEMPLATE_NOT_APPROVED |
422 | "DLT template is in '<status>' status; only 'approved' templates can be used." Batch + audience send also return this code for unknown or unprovisioned ids | Wait for dlt-status-poll to flip the template to approved, or fix the rejection and re-submit. |
DLT_TEMPLATE_NOT_PROVISIONED |
422 | Approved locally but the DLT provider issued no entity PEID / provider template id, so the carrier cannot scrub the message | Re-submit the entity and template to the DLT provider (also seen under DLT_PROVIDER=none / DLT_DRY_RUN=true). |
HIGH_COST_DESTINATION |
422 | The destination sits in a high-cost non-geographic range (+870, +878, +881/+8816, +882, +883 — satellite and international networks that bill dollars a message). Refused before the allowlist and in every environment: no allowlist can open a range Sendoka does not serve. Surfaces on POST /v1/sms, POST /v1/verifications, per-item in POST /v1/sms/batch, on the dashboard template test-send, and on POST /v1/audiences/{id}/send (SMS), where one such recipient refuses the whole blast |
Remove the recipient. Only well-formed E.164 numbers are judged, so a blast is never refused over a locally-formatted stored number. |
SUPPRESSION_PROTECTED |
409 | DELETE /v1/suppressions against a row whose reason is stop (the recipient texted STOP) or complaint (the recipient reported spam). Those are the recipient's decision, so no surface removes them — not the API, the dashboard or the MCP tool. bounce, manual and unsubscribe rows delete normally |
Nothing on your side lifts it, and retrying will not either. Re-subscription needs a new action by the recipient: a fresh SMS opt-in, or a new email subscription. |
QUERY_BODY_CONFLICT |
422 | DELETE /v1/suppressions named its target in both the query string and the JSON body, and they disagree — e.g. ?value=a@example.com with { "value": "b@example.com" }. Each field may come from either place; one sent in both must name the same thing (value is compared case-insensitively). Nothing was removed |
Send channel and value in one place, or the same values in both. |
PROJECT_NOT_EMPTY |
409 | DELETE /v1/projects/:id while any row still references the project. Projects have no cascade, and the guard covers every table with a project_id FK — keys, domains, messages, tenants, templates, usage, deliveries, … — not just the ones you can delete through the API. errors[] carries one { path: [table], message } per blocking table, e.g. { "path": ["tenants"], "message": "rows in tenants still reference this project" }. The probe is an EXISTS, so it names the tables that are non-empty and never how many rows are in them |
Delete or move the listed resources and retry. Tables you cannot empty through the API (messages, project_usage, daily_stats, message_status_events) mean the project has been used and is not deletable today — that was already true of messages before the other tables were added to the guard, so no project that used to delete cleanly stopped doing so. |
PROJECT_IS_DEFAULT |
409 | DELETE /v1/projects/:id against the org's default project — the fallback every legacy org-scoped row routes through |
Default projects cannot be deleted. Delete a non-default project instead. |
PROJECT_SLUG_TAKEN |
409 | POST /v1/projects or PATCH /v1/projects/:id with a slug another project in the org already holds |
Pick a different slug. Retrying the same one cannot succeed. |
Rate / quota — 429
| Code | Meaning | Recover by |
|---|---|---|
RATE_LIMITED |
Burst (per-plan or per-key) exceeded | Back off until X-RateLimit-Reset. Free=60/min, Pro=600, Enterprise=6000. |
USAGE_LIMIT_EXCEEDED |
Monthly plan email/SMS quota reached | Upgrade or wait for next period. |
SPEND_CAP_EXCEEDED (402) |
The send would push month-to-date metered spend past the org's spend cap. Metered tiers only; a send costing nothing (inside a plan allowance) is never refused, and the send landing exactly on the cap is the last one allowed. A message already scheduled when the cap starts binding is never refused or failed — the send cron defers it an hour at a time until the cap moves (see scheduled-sends.md) |
Raise or clear the cap in Settings → Billing, or wait for the period to roll. usage.alert webhooks fire at 80% and 100% before this does. |
WARMUP_LIMIT_EXCEEDED |
Domain is in warmup window; today's cap reached | Spread sends across days, or finish warmup. |
SANDBOX_DAILY_LIMIT |
Live sends from your org's sandbox sender are capped at 100 recipients (to + cc + bcc) per org per UTC day, across every sandbox sender the org holds; dashboard template test sends count too. The message gives the reset time (the next 00:00 UTC); there is no Retry-After, and the SDKs do not retry it. Email codes from POST /v1/verifications are refused past the cap but do not count toward it. Per-item in POST /v1/emails/batch |
Wait for the day to roll over, or add and verify your own domain, which has no such cap. |
REPLAY_RATE_LIMITED |
Too many webhook replays on this endpoint | 10/min per endpoint (shared by single + bulk). Wait. |
TEST_FIRE_RATE_LIMITED |
Too many synthetic events triggered | 20/min per endpoint. Wait. |
PROVISION_LIMIT_EXCEEDED |
Brand / campaign / number registrations, 10 per hour per org — each one costs real money at the registry. PATCH /v1/brands/:id shares the same bucket: it re-submits the registration already held, so it bills nothing, but it still spends registration versions and carrier review cycles |
Wait, or contact support for a higher cap. Test-mode provisioning has its own bucket, so CI cannot exhaust the live allowance. |
ENDPOINT_DISABLED (409) |
Replay/test-fire against a disabled webhook endpoint | Re-enable via PATCH /v1/webhooks/:id { "enabled": true } — clears the auto-disable streak. |
Not found — 404
| Code | Meaning |
|---|---|
NOT_FOUND |
ID doesn't exist in your org / tenant. |
Server — 500
| Code | Recover by |
|---|---|
INTERNAL_ERROR |
Retry; if persistent quote X-Request-Id in support. |
UNKNOWN |
Same — surfaces when the JSON response was unparseable. |
Provider — 502
When a downstream provider (AWS SES / SNS / Stripe) returns an error we couldn't classify, you'll see type: "server_error" with a 502 and the provider's message copied into message. Log the request_id and the provider trace if possible.
Codes the sections above do not list
Every code below is emitted by a /v1 route or by src/lib/api/* today
(grep apiError( / validationError(), grouped by type. Same columns as
the tables above.
authentication_error
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
SENDER_DOMAIN_NOT_VERIFIED |
403 | Live email send from a domain this org has not verified (or has never added) | Add + verify the domain via /v1/domains, then resend. Test keys skip this check. |
SCOPE_ESCALATION |
403 | POST /v1/keys from a scoped key that omitted scopes (a scoped key cannot mint a full-access one) or asked for a scope it does not hold |
Grant a subset of the calling key's scopes. |
ENVIRONMENT_ESCALATION |
403 | A sok_test_* key tried to act on live state. Two shapes: it tried to create, modify or revoke a live key (POST /v1/keys, PATCH/DELETE /v1/keys/{id}), or it called an operation that is live no matter which key asks because the row is shared between both modes — POST /v1/webhooks/{id}/rotate (that secret is the live signing secret), POST /v1/tenants/{id}/erase, DELETE /v1/tenants/{id} (archive) and POST /v1/tenants/{id}/suspend (all three take a live tenant offline and cancel its live scheduled backlog) |
Use a live key. Test keys stay unrestricted for sends, reads and their own test-mode resources. |
DOMAIN_SCOPE_ESCALATION |
403 | Key create / patch asked for allowed_domain_ids wider than the calling key's, or left it unrestricted when the caller is restricted |
Pass a subset of the caller's allowed domains. |
CIDR_SCOPE_ESCALATION |
403 | Same rule for allowed_cidrs |
Pass a subset of the caller's allowed CIDRs. |
EXPIRY_ESCALATION |
403 | Key create from an expiring key that omitted expires_in_days or asked for an expiry beyond its own |
Pass an expiry no later than the calling key's. |
RATE_LIMIT_ESCALATION |
403 | Key create / patch from a rate-limited key that omitted rate_limit_per_minute, cleared it, or asked for more than its own |
Pass a limit at or below the calling key's. |
PROJECT_SCOPED_KEY |
403 | POST /v1/projects from a key bound to one project |
Use an account-wide key. |
TENANT_KEY_FORBIDDEN |
403 | Two surfaces, same code: a tenant-bound key supplied X-Sendoka-Tenant-Ref (only a platform-root key may name a tenant), or it called a platform-only route — POST /tenants/:id/{suspend,unsuspend,restore,erase}, PATCH /tenants/:id/quota |
Drop the header, or use a platform-root key. |
TENANT_SUSPENDED / TENANT_ARCHIVED / TENANT_NOT_FOUND |
403 | The tenant a tenant-bound key belongs to is not usable, so every request on that key is refused up front | Unsuspend / restore the tenant with a platform-root key, or mint a key for a live tenant. (TENANT_NOT_FOUND also appears as a 422 validation_error below — different surface.) |
TENANT_NOT_USABLE |
409 | X-Sendoka-Tenant-Ref resolved to an existing tenant that is suspended or archived; auto-resolution never resurrects one |
Unsuspend / restore it explicitly, or send under a different ref. |
validation_error
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
INVALID_FROM |
422 | Live email from has no @domain part |
Send from name@domain or Name <name@domain>. |
HIGH_COST_DESTINATION |
422 | SMS to is in a satellite / premium range Sendoka does not serve (POST /v1/sms, /v1/verifications; per-item in /v1/sms/batch) |
Nothing — the range is refused in every environment. |
MMS_TOO_MANY_ATTACHMENTS |
422 | More media_url entries than the MMS cap (per-item in batch) |
Send fewer attachments. |
MMS_INVALID_MEDIA_URI |
422 | A media_url is not an s3://bucket/key URI — the provider accepts only S3 (per-item in batch) |
Upload to S3 and pass the s3:// URI. |
SCHEDULED_SINGLE_RECIPIENT |
422 | scheduled_at on an email with more than one to — the stored envelope holds one recipient |
Use cc / bcc, one send per recipient, or send now. |
SCHEDULED_NO_ATTACHMENTS |
422 | scheduled_at on an email carrying attachments — bytes are never stored |
Send at request time. |
TEMPLATE_TENANT_SCOPED |
422 | The template slug exists only on individual tenants and the send carried no tenant | Send with X-Sendoka-Tenant-Ref, or create an org-level template with that slug. |
IP_POOL_PAUSED |
409 | The sending domain is assigned to a dedicated IP pool that is paused | Resume the pool or unassign the domain. |
NO_SENDER |
422 | POST /v1/verifications without from — codes go out from your own sender or not at all |
Pass a registered number / sender ID (SMS) or an address on a verified domain (email). |
UNDELIVERABLE_DESTINATION |
422 | The verification destination cannot receive a code | Correct the destination. |
UNAVAILABLE |
503 | A verification could not be started, or POST /verifications/:id/check could not record the attempt — ours, and the code the user holds is still good |
Retry in a moment. |
SEND_FAILED |
502 | POST /v1/verifications: the provider refused the code. Carries type: "validation_error" despite the 502 — a case where branching on type alone would misroute it |
Retry; check the destination if it persists. |
BY_KEY_NOT_AVAILABLE |
422 | GET /v1/usage?by_key=true from a tenant-bound key — usage has no per-key breakdown at tenant scope |
Drop by_key, or use a platform-root key. |
SELF_REVOKE_FORBIDDEN |
422 | DELETE /v1/keys/:id naming the key making the request |
Revoke it from the dashboard or with another key. |
DOMAIN_ALREADY_ADDED |
409 | POST /v1/domains for a domain already in this project (names compare case-insensitively) |
Use the existing dom_ row. |
DOMAIN_RESERVED |
422 | POST /v1/domains (and the dashboard add) for sendoka.com or any name under it, including any org's {org-slug}.sandbox.sendoka.com host. That zone is Sendoka's own sending identity. Refused before the plan cap and any provider call |
Add a domain you control. Your own sandbox sender is already listed under Domains. |
DOMAIN_TAKEN_AT_PROVIDER |
409 | The email provider already holds this domain identity under another account | Contact support; the identity has to be released first. |
DOMAIN_LIVE_ONLY |
422 | Domain create / verify / delete with a sok_test_* key — domains have no simulated shape and test sends need none |
Use a live key. |
TEMPLATE_SLUG_TAKEN / AUDIENCE_SLUG_TAKEN / POOL_SLUG_TAKEN / PROJECT_SLUG_TAKEN |
409 | Create or rename collided with an existing slug in the same org (+ tenant) | Pick another slug. |
AUDIENCE_SEND_IN_PROGRESS |
409 | DELETE /v1/audiences/:id while a send job on it is scheduled or running |
Wait, or DELETE /v1/jobs/:id first. |
POOL_NUMBER_NOT_OWNED |
422 | A number in numbers is not provisioned + verified for this org (or is a test-mode number — pools have no environment) |
Provision and verify the number with a live key first. |
NO_PROVIDER_NUMBER |
422 | PATCH /v1/phone-numbers/:id with an empty body (re-check) on a row that never got a provider id |
Nothing to re-check; provision again. |
TENANT_NOT_FOUND |
422 | tenant_id in a brand / campaign / domain create is not a tenant of this org |
Use a ten_ id from GET /v1/tenants. |
TENANT_EXTERNAL_REF_CONFLICT |
409 | external_ref already belongs to another tenant |
Reuse that tenant, or pick another ref. |
TENANT_NOT_ACTIVE |
409 | DELETE /v1/tenants/:id (archive) on a tenant that is not active |
Only active tenants archive. |
TENANT_ARCHIVED |
409 | POST /v1/tenants/:id/suspend on an archived tenant |
Restore first, then suspend. |
TENANT_NOT_SUSPENDED |
409 | POST /v1/tenants/:id/unsuspend on a tenant that is not suspended (including one that changed state mid-request) |
Read the tenant's status and act on it. |
TENANT_NOT_ARCHIVED |
409 | POST /v1/tenants/:id/restore on a suspended (not archived) tenant |
Unsuspend instead. Restoring an already-active tenant is a 200 with restored: false. |
PROJECT_IS_DEFAULT |
409 | DELETE /v1/projects/:id on the account's default project |
It cannot be deleted. |
PROJECT_NOT_EMPTY |
409 | DELETE /v1/projects/:id while resources still reference it; the message lists what |
Move or delete them first. |
CAMPAIGN_IS_LIVE_MODE / PHONE_NUMBER_IS_LIVE_MODE |
422 | Same rule as BRAND_IS_LIVE_MODE for campaigns and numbers: a test key may only touch a resource carrying a simulated test-reg-… id |
Use a live key. |
rate_limit_error
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
TENANT_QUOTA_EXCEEDED |
429 | The tenant's own monthly quota (PATCH /v1/tenants/:id/quota) is used up |
Raise the tenant's quota or wait for the period. |
PLAN_RESOURCE_LIMIT |
402 | The plan's cap on a countable resource — sending domains, webhook endpoints, tenants, dedicated IP pools, API keys, team seats — is reached; the message names which. Webhook endpoints are capped per scope — org-level endpoints against one copy of the cap, each tenant's against its own — and the message names the scope that is full (organization-level webhook endpoints, or webhook endpoints for tenant ten_…) |
Upgrade, or delete one in the scope the message names. |
DEDICATED_IP_LIMIT_EXCEEDED |
402 | Requested dedicated IPs would exceed the plan's cap | Upgrade, or release IPs. |
KEY_CREATE_RATE_LIMITED |
429 | More than 10 POST /v1/keys in an hour |
Wait. |
TENANT_AUTO_CREATE_RATE_LIMITED |
429 | Too many never-seen X-Sendoka-Tenant-Ref values created tenants in the window |
Pre-create tenants with POST /v1/tenants, or slow the rollout. |
PROVIDER_QUOTA_EXCEEDED |
429 | An audience blast would push the SES account past its 24h send quota; the message carries the numbers | Wait for the window, split the blast, or request a quota increase. |
server_error
| Code | HTTP | Meaning | Recover by |
|---|---|---|---|
PROVIDER_UNAVAILABLE |
502 | A domain create / re-verify or a number re-check could not reach the provider | Retry with backoff. |
PROVIDER_EMPTY |
502 | The provider leased a number but returned no E.164 for it; the lease is released and nothing is stored | Retry; report if it persists. |
AUDIENCE_SEND_INCOMPLETE |
500 | POST /v1/audiences/{id}/send: the job was created but scheduling its messages failed part-way, or the request was cut off before it recorded how it ended (the replay of a provisional record written with the job). error.job_id names it; rows that landed will send unless the job is canceled. Recorded under the Idempotency-Key, so a retry with the same key replays this answer rather than blasting again — the SDKs do not retry it. Without a key nothing is recorded, and a retry is a whole second blast |
GET /v1/jobs/{job_id} to see how many landed, DELETE it to stop them, then send under a new key. |
Per-item codes inside a batch response
These appear in data[].error.code of /v1/emails/batch and /v1/sms/batch,
never as a top-level error.code. The HTTP status of the batch itself is 200.
| Code | Meaning |
|---|---|
PROVIDER_ERROR |
The provider refused this item. With a non-null id the failure row is stored and message.failed fires; with id: null the row could not be stored either — the response is the only trace, and retrying the item is safe. |
MESSAGE_NOT_RECORDED |
The provider accepted the item (it was delivered and is billed) but the message row could not be stored. status: "sent", id: null. Do not retry. |
VALIDATION_ERROR, SUPPRESSED, SENDER_NOT_VERIFIED, HIGH_COST_DESTINATION, SMS_DESTINATION_NOT_ALLOWED, MMS_*, DLT_*, UNSUBSCRIBE_*, WARMUP_LIMIT_EXCEEDED, SANDBOX_RECIPIENT_NOT_ALLOWED, SANDBOX_DAILY_LIMIT |
Rejected before any send attempt — id: null, nothing fanned out. Same meaning as the top-level code of the same name. |
Reading validation errors
try {
await mr.emails.send({ from, to, subject, html });
} catch (err) {
if (err.code === "VALIDATION_ERROR") {
for (const issue of err.errors ?? []) {
console.log(issue.path.join("."), issue.message);
}
} else if (err.code === "RATE_LIMITED") {
await new Promise((r) => setTimeout(r, reset - Date.now()));
}
}
Headers
Returned on every response, not just 429:
X-Request-Id: req_Hg8JpNvKXWg4f
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 1713820800
X-RateLimit-Plan: pro
X-RateLimit-Scope: org | key
Recovery recipes
| What happened | Do this |
|---|---|
429 RATE_LIMITED |
Sleep until X-RateLimit-Reset (unix ms), then retry. |
429 USAGE_LIMIT_EXCEEDED |
Upgrade or wait; no retry helps. |
409 IDEMPOTENCY_IN_FLIGHT |
Retry in ~500 ms under the same key — on an audience send the blast is still scheduling and can take minutes, so retry later to get its job_id. |
409 IDEMPOTENCY_MISMATCH |
Generate a fresh UUID key. |
500 AUDIENCE_SEND_INCOMPLETE |
Check GET /v1/jobs/{job_id}, cancel it if needed, then send under a new key. Never just retry under a new key. |
422 VALIDATION_ERROR |
Inspect errors[]; fix fields; do not retry blindly. |
422 TEMPLATE_MISSING_VARIABLES |
Include the missing {{var}} in variables. For an audience blast, no recipient had the key: fix the typo or pass it in variables. |
422 DLT_TEMPLATE_* |
Register + approve a DLT template, then resend with dlt_template_id (India DLT). Retrying unchanged never succeeds. |
403 DOMAIN_TENANT_MISMATCH |
Send from a domain your tenant owns. |
| 500 / 502 | Retry with exponential backoff; after 3 failures, open a ticket with request_id. |