SMS

POST /api/v1/sms

Send a single SMS via AWS SNS. Source: src/app/api/v1/sms/route.ts. Scope: send:sms.

Request

{
  "from": "+15555550100",
  "from_pool": "support",
  "to": "+15551234567",
  "body": "Your code: 123456",
  "tags": ["otp"],
  "metadata": { "flow": "login" },
  "scheduled_at": "2026-05-18T15:30:00Z",
  "scheduled_local": "2026-05-18 09:00",
  "scheduled_at_tz": "America/New_York",
  "template": "otp-sms",
  "variables": { "code": "123456" },
  "dlt_template_id": "dlt_..."
}

Validation (sendSmsSchema):

Field Type Rules
from string E.164 number, alphanumeric sender ID, or omit when from_pool is set
from_pool string slug of a messaging pool — sticky-per-recipient picker; tenant-scoped
to string E.164 — regex ^\+[1-9]\d{1,14}$
body string 1–1600 chars. Required unless template supplies it
media_url string[] MMS attachments as S3 URIs (s3://bucket/key), max 10 — see MMS
tags string[] max 10
metadata Record<string,string> optional
scheduled_at ISO 8601 UTC future → status: "scheduled"
scheduled_local + scheduled_at_tz string + IANA tz naive local time + zone, converted at dispatch
template string slug of an SMS template
variables object Substituted into the template body. Any JSON per key, so arrays for {{#each}} and objects for dotted paths; max 10 levels deep and 64 KB serialized (limits)
dlt_template_id string dlt_... id of an approved DLT template. Required for +91 recipients, only enforced for them — see India DLT

Either from or from_pool is required. Either body or template is required.

Response — 200

{ "id": "msg_...", "channel": "sms", "status": "sent", "segments": 2, "created_at": "..." }

Scheduled: same envelope with status: "scheduled" + scheduled_at.

segments is the billable segment count for the body as sent (after any template render) — the exact unit metered against your plan, not an estimate. A 300-character GSM-7 body bills as 2, not 1. It is also returned on GET /v1/sms/{id} and on each row of GET /v1/sms, so spend can be reconciled per message. See usage-limits.

Pre-send gates (live env)

  1. phone_numbers row exists for (orgId, sender) and is verified:
    • If from is E.164 (^\+[1-9]\d{1,14}$), looked up by e164 against kind = 'number' rows
    • Otherwise (alphanumeric like Acme), looked up by sender_id against kind = 'alphanumeric' rows
  2. Recipient not on the suppression list for (orgId, "sms", to, tenantId)

Test-mode (sok_test_*) keys skip the sender gate — provider is simulated with test_msg_* ids. Any from is accepted in test mode. When a live key would refuse it, the 200 response carries an advisory warnings array naming the code the live send would get, so a CI suite can catch it before go-live:

{
  "id": "msg_...", "channel": "sms", "status": "sent", "segments": 1, "created_at": "...",
  "warnings": [
    { "code": "SENDER_NOT_REGISTERED", "message": "Test keys skip the sender check, so this send was simulated. A live key would be refused: ..." }
  ]
}

The field is absent when the sender would pass, and never appears on a live send. A number leased with a test key is simulated, so it draws the warning too, and so does a US number without an approved carrier registration (SENDER_CAMPAIGN_NOT_APPROVED). Until Sendoka enforces that last rule, a live send from such a number is not refused here, so its warning says instead that carriers will block or filter the traffic without an approved registration. Only POST /v1/sms returns it; batch items do not.

Pre-send gate (every env)

The India DLT gate runs before the sender and suppression gates, and — unlike them — is not skipped by test-mode keys. It only engages for +91 recipients. See India DLT.

The org's destination-country allowlist is enforced in every env too, before the DLT gate and before anything is sent or billed. Refusal is a 403 SMS_DESTINATION_NOT_ALLOWED. See Destination permissions. The same two destination gates (high-cost range, then allowlist) run on every SMS surface that ACCEPTS a send: /v1/sms, /v1/sms/batch, /v1/verifications, the audience blast (/v1/audiences/{id}/send and its dashboard twin, where one refused recipient refuses the whole blast) and the dashboard template test-send.

Both are enqueue-time gates. A message already sitting in scheduled when the allowlist is narrowed still fires — the send cron re-checks the sender, the tenant, suppression and the caps at fire time, but not the destination gates. Cancel the scheduled messages (DELETE /v1/emails/{id} / /v1/sms/{id}, or the audience job) if narrowing the list has to take effect on traffic already queued.

Two sender kinds

Kind Use for How to register
number US 10DLC long codes + US/CA toll-free POST /v1/phone-numbers (default) — AWS provisions, status pending → verified. A US long code is leased under a verified 10DLC campaign_id (required); a US toll-free number sends once its toll-free verification (a kind: "tollfree" campaign naming it) is approved
alphanumeric UK / AU / most of EU direct-to-carrier; India via a DLT header POST /v1/phone-numbers with kind: "alphanumeric", sender_id, iso_country (+ dlt_header_id for IN) — leased with AWS RequestSenderId; status verified immediately, or 422 SENDER_ID_REJECTED where the country requires a sender-ID registration

US and CA reject the alphanumeric path: they require 10DLC or toll-free numbers. IN accepts it only as a registered DLT header — see India DLT.

A US number must also have its carrier registration approved: a live send from a long code with no approved 10DLC campaign, or from a toll-free number whose verification is not yet verified, is 422 SENDER_CAMPAIGN_NOT_APPROVED — AWS reports both numbers ACTIVE, and the carrier would block the traffic. See SMS Sender Registration.

Errors

HTTP Code When
413 PAYLOAD_TOO_LARGE Body > 6MB, or the template passes a render cap: its {{#each}} blocks run more than 5,000 iterations or render more than 2,000,000 characters, or the text renders more than 10,000,000 characters
403 SMS_DESTINATION_NOT_ALLOWED Destination country not on the org's allowlist — see Destination permissions
422 SUPPRESSED Recipient on STOP / bounce list for this org+tenant
422 SENDER_NOT_REGISTERED Live key, from-number not provisioned
422 SENDER_NOT_VERIFIED Live key, from-number provisioned but not yet verified
422 SENDER_CAMPAIGN_NOT_APPROVED Live key, US from-number with no approved 10DLC campaign / toll-free verification
422 TEMPLATE_MISSING_VARIABLES Template references vars not supplied
422 MMS_INVALID_MEDIA_URI A media_url entry is not an s3://bucket/key URI
422 MMS_TOO_MANY_ATTACHMENTS More than 10 media_url entries
422 MMS_NOT_SUPPORTED media_url to a +91 recipient under DLT enforcement — see MMS. A failed message row is stored
422 DLT_TEMPLATE_REQUIRED +91 recipient, no dlt_template_id — see India DLT
422 DLT_TEMPLATE_NOT_FOUND dlt_template_id doesn't exist in this org
422 DLT_TEMPLATE_NOT_APPROVED Template exists but isn't in approved status
422 DLT_TEMPLATE_NOT_PROVISIONED Template is approved locally but the DLT provider issued no PEID / provider template id
409 IDEMPOTENCY_IN_FLIGHT Same Idempotency-Key still processing
409 IDEMPOTENCY_MISMATCH Same key, different body
429 USAGE_LIMIT_EXCEEDED / TENANT_QUOTA_EXCEEDED Plan or per-tenant cap

Full envelope shape: see errors.md.


POST /api/v1/sms/batch

Scope: send:sms. maxDuration pinned to 300s. Accepts two shapes:

Shape 1 — array of per-item payloads (1–100 items)

Use when each recipient needs a different from / body / tags.

[
  { "from": "+15555550100", "to": "+15555550199", "body": "Personalized 1", "tags": ["promo"] },
  { "from": "+15555550100", "to": "+15555550200", "body": "Personalized 2", "tags": ["promo"] }
]

Shape 2 — bulk broadcast (one body to N recipients)

Use when the same from + body goes to many recipients. Up to 100 recipients per call.

{
  "from": "Ezzeefy",
  "to":   ["+61470594555", "+61470594556", "+61470594557"],
  "body": "Sale ends tonight",
  "tags": ["promo"],
  "metadata": { "campaign": "sale_q2" }
}

Internally fans out to per-recipient items; the gates and response shape are identical to Shape 1.

Pre-send gates run once for the whole batch, not per item:

  • Plan + per-tenant quota checked against items.length
  • All recipient numbers passed through filterSuppressed — suppressed items fail with code: SUPPRESSED
  • The org's destination allowlist is loaded once; the verdict is per item, so a recipient in a country off the list fails with SMS_DESTINATION_NOT_ALLOWED while the rest of the batch still sends
  • Distinct from numbers looked up once in phone_numbers; live items whose from isn't verified fail with a per-item error
  • Every dlt_template_id on a +91 item is resolved in one query; the DLT verdict is then per item, so one unregistered India recipient fails with DLT_TEMPLATE_REQUIRED / DLT_TEMPLATE_NOT_APPROVED while the rest of the batch still sends. The batch path collapses "not found", "not approved" and "not provisioned" into DLT_TEMPLATE_NOT_APPROVED — single-send distinguishes them

Each item is sent in parallel under BATCH_SEND_CONCURRENCY=10. Result preserves input order.

An item the provider refused (PROVIDER_ERROR) is recorded like a failed single-send: when that row lands it comes back with an id, and the message is visible in the dashboard and through GET with status=failed. A message.failed webhook fires for it — batch only; single-send records the row but sends no webhook. Items rejected by a pre-send gate (SUPPRESSED, SENDER_NOT_VERIFIED, DLT_TEMPLATE_REQUIRED, …) have "id": null — nothing was ever attempted for them, and nothing is fanned out.

Rare third result case: "status": "sent" with "id": null and error code MESSAGE_NOT_RECORDED. The provider accepted the message — it was delivered and is billed — but the message row could not be stored, so there is no id to fetch and no message.sent webhook fires for it. Do not retry the item; retrying sends a duplicate.

Rare fourth result case: "status": "failed" with "id": null and error code PROVIDER_ERROR. The provider refused the item and the failure row could not be stored, so there is no id to fetch, the item is absent from the dashboard and from status=failed, and no message.failed webhook fires — the response body is the only trace of it. Unlike MESSAGE_NOT_RECORDED, retrying is safe: nothing was delivered. error.message still carries the provider's rejection.

Not supported in batch (rejected with a per-item validation error so the rest of the batch proceeds):

  • template — render templates upstream, send the rendered body
  • scheduled_at / scheduled_local — schedule via single-send
  • from_pool — pass an explicit from per item

Response:

{
  "data": [
    { "index": 0, "id": "msg_...", "status": "sent",   "error": null },
    { "index": 1, "id": null,      "status": "failed", "error": { "code": "SUPPRESSED", "message": "recipient is suppressed" } }
  ],
  "total": 2,
  "succeeded": 1,
  "failed": 1
}

GET /api/v1/sms

List SMS, env-filtered. Scope: read:messages.

Query params: limit (max 100), status, tag, to (exact E.164 match), created_after / created_before (ISO 8601, inclusive), cursor (opaque, from prior response).

Each message carries tenant_id — the tenant it was sent for in platform mode, null otherwise (the by-id read returns it too). To list one tenant's SMS, send X-Sendoka-Tenant-Ref or read with a tenant-bound key; see platforms → reading one tenant's traffic.


GET /api/v1/sms/{id}

Single SMS, env-filtered + tenant-scoped. Scope: read:messages. Returns 404 across env boundaries.


DELETE /api/v1/sms/{id}

Cancel a scheduled SMS. Scope: send:sms. Returns 409 NOT_SCHEDULED if the row is already sent/delivered/etc. Emits message.canceled to the audit log.


Delivery status

An AWS End User Messaging (Pinpoint SMS v2) event destination publishes to a topic that fans out to /api/webhooks/sns-sms — not SNS SMS delivery logging, which writes to CloudWatch Logs and never reaches a topic. The handler:

  1. Verifies SNS signature (Signature v2 only, one-hour replay window sized to the SNS retry policy, sns.*.amazonaws.com cert host)
  2. Enforces SNS_TOPIC_ARN_ALLOWLIST — required in production, where an unset value rejects every notification (fails closed); unset outside production skips the check
  3. Rate-limits 600/min per source IP
  4. For SubscriptionConfirmation: validates SubscribeURL host before confirming
  5. For Notification: matches providerMessageId on the messages row
  6. Maps the AWS status. Four disjoint sets, and the boundaries matter:
    • Delivered — SUCCESS / DELIVERED / TEXT_DELIVERED / TEXT_SUCCESSFUL / MMS_DELIVERED / MMS_SUCCESSFUL → delivered + message.delivered
    • Bounced + auto-suppressed — only tokens meaning the recipient is permanently unreachable: INVALID_NUMBER / INVALID / OPTED_OUT and their TEXT_* / MMS_* twins
    • Failed, no suppression — FAILURE, the TTL/expiry and unreachable tokens (the handset was off or out of coverage — suppressing on those once blocked a valid recipient for good), plus the sender-side faults: carrier filtering (BLOCKED / CARRIER_BLOCKED / SPAM and their TEXT_* / MMS_* twins — an unregistered or flagged sender, not an unreachable handset), *_INVALID_MESSAGE (our body was refused) and the MMS_FILE_* media faults. Note *_INVALID without the suffix is the recipient-side twin and does suppress
    • In flight — TEXT_PENDING / TEXT_QUEUED / MMS_PENDING and friends are acknowledged and ignored. They used to fall into failed, which is terminal, so the real TEXT_DELIVERED that followed could no longer repair the row
    • An unrecognized token logs webhook.sns_sms.unmapped_status and changes nothing, rather than being guessed as a failure
  7. Inbound replies: every reply, keyword or not, is attributed to the tenant of the newest live send from that number to that handset in the last 90 days, falling back to the receiving number's tenant, so on a number shared by several tenants a reply lands with the tenant whose message it answers. An opt-out keyword adds a tenant-scoped suppression via the matching prior send, stores an inbound_sms row (readable via GET /v1/inbound) and fires a suppression.created webhook — not inbound.sms, see events.md. What counts as an opt-out depends on the word:
    • STOP as the first word — STOP, Stop., stop texting me all opt out. It is the keyword every campaign advertises, so a reply that leads with it means it even when more words follow. Buried in a sentence (please stop) it is a sentence.
    • STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT as the whole message — trailing punctuation and whitespace are fine (Cancel., quit!), anything else is not. These are ordinary English: Can I cancel my 3pm? and when does the sale end? used to suppress the handset and withhold the reply from inbound.sms; now they are conversation and flow through the inbound path.
    • The campaign's own stop_keyword words as the whole message — a number bound to a campaign with stop_keyword: "OptOut, Remove" treats a bare remove! as a STOP, since AWS registers those words as opt-outs on the number. Only the campaign bound to the number that received the reply counts.
    • Matching is case-insensitive and ignores non-letters, so STOP2, > STOP and 🛑 STOP are a STOP. A real word in front of it is not: Re: STOP and ok STOP are stored and delivered as inbound.sms instead. That is the deliberate cost of the first-word rule — it is what makes please stop the reminders a sentence — and nothing is dropped, it just arrives as a reply rather than an opt-out.
    • HELP / INFO follow the whole-message rule for the same reason (I need help with my order is a support request). A bare HELP is logged for ops visibility; AWS handles the carrier-required response.
    • The suppression such a reply writes carries reason: "stop" and cannot be deleted through the API, the dashboard or MCP (409 SUPPRESSION_PROTECTED, see errors.md). Only a fresh opt-in by the recipient lifts it.
    • START / UNSTOP as the whole message is that opt-in. It removes the reason: "stop" rows a STOP from that handset to that number can have been written under — the org-wide row, the number's owning tenant, and every tenant that sent a live message from the number to the handset in the last 90 days, since on a number shared by several tenants the newest sender is often not the one whose STOP is on file — plus any reason: "bounce" row with source: "sns_opt_out" (a send the carrier refused because the handset had opted out). Never a dead-number bounce, a complaint or a manual entry. If the STOP had landed on a row you created yourself (a manual block, an unsubscribe), that row is put back as it was rather than deleted. Each change is written to the audit log as suppression.removed with source: "sms_start". The reply then arrives as an ordinary inbound.sms so your app knows the handset is reachable again. Do not send a confirmation yourself: Sendoka registers START and UNSTOP on every live number with AWS's opt-in action and a brand-named confirmation, so AWS has already sent the one carriers expect — a second from your app is a duplicate. YES is not an opt-in keyword — it is how people answer "Reply YES to confirm" — and start time is 3pm? is conversation.

See integrations/aws-sns.md for the topic setup.


MMS

Attach media by passing media_url. MMS routes through AWS End User Messaging (SendMediaMessage) rather than the SNS PublishCommand the text path uses — SNS cannot carry media at all, and the AWS.MM.SMS.MediaUrls attribute is not a valid SNS reserved attribute, so setting it risks AWS rejecting the publish.

{
  "to": "+14155550142",
  "from": "+18885550100",
  "body": "Your receipt",
  "media_url": ["s3://my-bucket/receipt.png"]
}
  • S3 URIs only (s3://bucket/key). An https link is rejected with MMS_INVALID_MEDIA_URI — media_url reads like it takes one, and SendMediaMessage does not.
  • Up to 10 attachments; more is MMS_TOO_MANY_ATTACHMENTS.
  • Not available for +91 under DLT enforcement. DLT scrubs text against a registered template and MMS is not part of that regime, so an MMS there would be an unscrubbed send — the one thing the India path refuses to do. POST /v1/sms answers 422 MMS_NOT_SUPPORTED (until 2026-11 it was a generic 500, which the SDKs retried); a batch item fails as PROVIDER_ERROR.
  • Classified by the origination number's own 10DLC registration, set once at provisioning. There is no per-send message type for MMS the way SMSType exists for SMS.

Validation happens at the route rather than at the provider, so a bad URI is a 422 naming the problem instead of an AWS error on a send you have already been billed for. In /v1/sms/batch each item is validated on its own, so one bad URI fails that item and the rest of the batch proceeds.


Destination permissions

The org-level destination-country allowlist — the AIT / SMS-pumping defense. A leaked live key can otherwise pump traffic at whatever country monetizes best, and most orgs only ever text one or two markets; capping destinations caps the blast radius of the key.

Where it lives. Settings → SMS destinations (owner-only to change; every member can view). The setting is a list of ISO 3166-1 alpha-2 country codes:

Value Meaning
not configured (default) Every destination allowed
["US", "CA", "AU"] Only those countries
empty list Every SMS refused — "pause all SMS" as an incident response

Where it's enforced. POST /v1/sms, POST /v1/sms/batch (per item, like SUPPRESSED — a refused destination fails alone while the rest of the batch proceeds) and POST /v1/verifications with channel: "sms". Always before any provider call and before anything is metered, and in every environment — the refusal is a property of the org's configuration, so a sok_test_* key gets the same answer a live send would.

The refusal.

{
  "error": {
    "type": "authentication_error",
    "message": "Destination country is not on this organization's allowed SMS destinations. An owner can change the list in Settings.",
    "code": "SMS_DESTINATION_NOT_ALLOWED"
  }
}

HTTP 403 on the single-destination surfaces; the standard per-item error: { code, message } shape inside a batch; on an audience blast the whole blast is refused (the message says how many recipients are off the list) rather than silently shrinking the list the org asked to text.

Shared calling codes (+1, +44, +7, …). A number on a shared calling code is resolved to one country by its national prefix, and that country must be on the list. For +1 that is the area code: +1 212 … is the United States, +1 416 … Canada, +1 876 … Jamaica and +1 809 … the Dominican Republic, so a list of ["US"] refuses the last two. Caribbean +1 destinations terminate at international rates while dialling like a US number, which makes them a favourite target of SMS pumping. +44 is narrowed the same way (Guernsey, Jersey and the Isle of Man are separate from GB), as are +7 (Russia or Kazakhstan) and +39 (Italy or Vatican City).

US territories are separate countries. Puerto Rico (PR, area codes 787 and 939), the US Virgin Islands (VI, 340), Guam (GU, 671), American Samoa (AS, 684) and the Northern Mariana Islands (MP, 670) each resolve to their own code, not to US. A list of ["US"] refuses them; add each territory you text, e.g. ["US", "PR", "VI", "GU", "AS", "MP"].

When the prefix cannot name a country — a +1 toll-free (8XX) or 5XX personal-communications code (geographic 5XX area codes such as 503 Oregon are ordinary US numbers), a malformed area code, or +590, which Guadeloupe, Saint Barthélemy and Saint Martin share — the destination is allowed only if every country it could belong to is on the list. In practice a +1 toll-free number (800, 833, 844, 855, 866, 877, 888) passes only when every +1 country, the Caribbean included, is listed; a ["US"] or ["US", "CA"] list refuses it.

High-cost ranges are not countries. The satellite / international-network ranges (+870, +878, +881, +882, +883) are refused with 422 HIGH_COST_DESTINATION before the allowlist is consulted and stay refused regardless of it — an allowlist cannot open a range the platform never serves.

Defensive read. A stored value that cannot be parsed is treated as "not configured" (allow all) and logged, never a 500 on the send path — the write path validates strictly, so a garbled value means operator drift, and blocking all traffic over bookkeeping would turn a bug into an outage.


India DLT

Indian carriers scrub every message against the TRAI DLT registry and silently drop anything that doesn't match a registered template — the publish still returns a provider message id, so an unregistered send would be recorded sent, metered, and fanned out as message.sent for a message nobody received. Sendoka therefore blocks at send time instead.

When it applies. Any to starting with +91, on /v1/sms, /v1/sms/batch and POST /v1/audiences/{id}/send (channel: "sms"). It is not limited to live mode — sok_test_* keys are gated too, so a test send exercises the same rule. Set DLT_ENFORCE=false to disable enforcement entirely (dev / migration escape hatch; on by default).

The sender. from is the DLT header the template was registered under, registered as an India sender ID: POST /v1/phone-numbers with kind: "alphanumeric", iso_country: "IN", sender_id = the header and dlt_header_id = its dlh_… id. The header must be verified under a verified entity with a PEID; see SMS Sender Registration § India.

What you pass. dlt_template_id — the dlt_... id of a template registered from the dashboard at /overview/dlt (POST /api/internal/dlt/templates, which also needs a DLT entity and header). The template must be in approved status and carry the provider-side ids the DLT provider hands back; the dlt-status-poll cron flips submitted → approved and fills those in.

What happens on the wire. The gate resolves the entity PEID + the provider's own template id and passes them into the SNS publish as the AWS.MM.SMS.EntityId / AWS.MM.SMS.TemplateId message attributes — both or neither, since a partial reserved-attribute set risks AWS rejecting the publish. Your dlt_template_id is also persisted on the message row (messages.dlt_template_id) so a scheduled send fired hours later by the send-scheduled cron, or a resend, can re-resolve the provider ids at fire time. The provider itself re-checks the pair for +91 and throws rather than publish unscrubbed, so a template that loses its provider ids between scheduling and dispatch fails the send instead of dropping it silently.

Rejections (all 422, all type: "validation_error"):

Code Meaning
DLT_TEMPLATE_REQUIRED +91 recipient and no dlt_template_id on the request
DLT_TEMPLATE_NOT_FOUND The id doesn't belong to this org
DLT_TEMPLATE_NOT_APPROVED Row exists but status is pending / submitted / rejected
DLT_TEMPLATE_NOT_PROVISIONED approved locally but no PEID / provider template id — happens under DLT_PROVIDER=none or DLT_DRY_RUN=true. Re-submit the entity and template to the provider
// POST /api/v1/sms
{
  "from": "ACMEIN",
  "to":   "+919876543210",
  "body": "Acme: order 1234 shipped. Track at acme.example/t/1234",
  "dlt_template_id": "dlt_..."
}

The DLT gate is independent of the sender gate: registering a DLT header does not create a phone_numbers row, so a live India send still has to clear assertSenderVerified on whatever from it uses, and the alphanumeric registration path rejects iso_country: "IN" (ALPHANUMERIC_FORBIDDEN_COUNTRIES). Test-mode keys skip the sender gate but not the DLT gate.


SMS provider constraints

  • AWS.SNS.SMS.SMSType is always Transactional on direct publish. Promotional traffic should ride through a 10DLC campaign provisioned with MessageType: PROMOTIONAL (see features/sms-registration.md).
  • AWS.SNS.SMS.SenderID is only set when from is non-E.164 (alphanumeric originator for UK/EU/IN). US/CA E.164 numbers omit the attribute — AWS rejects it for those countries.
  • MMS is not supported. See MMS.

Postman collection

Importable v2.1 collection with every request below. Saved at docs/api/sendoka-sms.postman_collection.json.

How to import

  1. Postman → File → Import → drop the JSON
  2. Open the collection's Variables tab:
    • baseUrl → your deploy (http://localhost:3000, https://YOUR-HOST)
    • apiKey → sok_test_... for safe smoke tests, sok_live_... for real sends
    • fromNumber / toNumber → defaults are +15555550100 / +15555550199
  3. A pre-request script auto-generates a fresh {{idempotencyKey}} per send.

Requests in the collection

Folder Request Notes
Send Send SMS — minimal from + to + body, with Idempotency-Key
Send Send SMS — template + variables Renders an SMS template by slug
Send Send SMS — scheduled (local + tz) scheduled_local + scheduled_at_tz
Send Send SMS — pool sender from_pool sticky picker
Send Send SMS — tags + metadata tags, metadata
Batch Batch SMS — 3 items Hits /v1/sms/batch
Read List SMS limit / status / tag / cursor
Read Get SMS by id Set {{smsId}} from a prior response
Mutate Cancel scheduled SMS DELETE on a scheduled row
Provision Brand (10DLC) display_name, ein, vertical, website, email
Provision Campaign Binds to {{brandId}}
Provision Phone number Binds to {{campaignId}}; iso_country, type

Ready-to-paste payloads (also embedded as collection request bodies):

Send — minimal

curl -X POST $baseUrl/api/v1/sms \
  -H "Authorization: Bearer $SENDOKA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": "+15555550100",
    "to":   "+15555550199",
    "body": "Test from Sendoka"
  }'

Send — template

{
  "from": "+15555550100",
  "to":   "+15555550199",
  "template": "order_shipped",
  "variables": {
    "order_id":     "ord_abc123",
    "tracking_url": "https://track.example.com/ord_abc123"
  }
}

Send — scheduled (local + tz)

{
  "from": "+15555550100",
  "to":   "+15555550199",
  "body": "Reminder: appointment tomorrow at 9am",
  "scheduled_local": "2026-05-18 09:00",
  "scheduled_at_tz": "America/New_York"
}

Send — pool

{
  "from_pool": "support",
  "to":        "+15555550199",
  "body":      "Your support ticket has an update."
}

Send — tags + metadata

{
  "from": "+15555550100",
  "to":   "+15555550199",
  "body": "Receipt attached. Reply STOP to opt out.",
  "tags": ["transactional", "receipt"],
  "metadata": {
    "order_id": "ord_abc123",
    "user_id":  "usr_42"
  }
}

Batch

[
  { "from": "+15555550100", "to": "+15555550199", "body": "msg 1", "tags": ["batch"] },
  { "from": "+15555550100", "to": "+15555550200", "body": "msg 2", "tags": ["batch"] },
  { "from": "+15555550100", "to": "+15555550201", "body": "msg 3", "tags": ["batch"] }
]

Register alphanumeric sender (UK / AU / EU)

// POST /api/v1/phone-numbers
{
  "kind": "alphanumeric",
  "sender_id": "Acme",
  "iso_country": "GB"
}

Response is the same phone_numbers row shape with kind: "alphanumeric", e164: null, sender_id: "Acme", status: "verified". Use the sender ID directly as the from on subsequent sends:

// POST /api/v1/sms
{
  "from": "Acme",
  "to":   "+447700900000",
  "body": "Welcome to Acme!"
}

409 SENDER_ID_UNAVAILABLE if another organization holds the sender_id or one that reads the same (case, separators and look-alike digits ignored), and 422 SENDER_ID_RESERVED if it names a bank, carrier, government body, major brand or Sendoka — that one needs manual approval. Both apply to test keys too. 409 SENDER_ALREADY_REGISTERED if the same sender_id is already on the org, unless only a test key registered it: a live registration then replaces the test-mode row (see alphanumeric senders). 422 if iso_country is US/CA/IN.

Brand → Campaign → Phone (10DLC provisioning order)

The registry vets a brand on its legal identity, not its marketing name. A display-name-only submission is accepted here and rejected days later, so send the whole block:

// POST /api/v1/brands
{
  "display_name":       "Acme Co",
  "legal_company_name": "Acme Holdings Pty Ltd",
  "entity_type":        "private_profit",
  "ein":                "12-3456789",
  "vertical":           "RETAIL",
  "website":            "https://acme.example",
  "email":              "compliance@acme.example",

  "street":       "1 Market St",
  "city":         "San Francisco",
  "state_region": "CA",
  "postal_code":  "94105",
  "country":      "US",

  "contact_first_name": "Dana",
  "contact_last_name":  "Reed",
  "contact_phone":      "+14155550100"
}

entity_type is one of private_profit, public_profit, non_profit, government, sole_proprietor, and it drives which of the other fields the registry insists on — a listed company is vetted against its exchange filing (stock_symbol + stock_exchange), a sole proprietor is not.

Then read provider_missing_fields on the response. Three distinct answers:

Value Meaning
[] Complete.
non-empty Accepted, but the registry will reject it — these are the field paths it required and this submission lacked.
null The registry's field list could not be read, so nothing is known. Not the same as [].

Non-empty is fixable in place — see Correcting a brand below.

A campaign needs the carrier consent block as well as the use case. US 10DLC has required a described opt-in flow since 2026-06-30, and a toll-free verification is largely made of these fields:

// POST /api/v1/campaigns
{
  "brand_id":   "brd_...",
  "name":       "Order alerts",
  "kind":       "10dlc",
  "use_case":   "account_notification",
  "description": "Transactional order shipping and delivery notifications. Recipients opt in at checkout; opt out via STOP keyword. Frequency: 1-3 msgs per order.",
  "sample_messages": [
    "Acme: order #1234 shipped — track at acme.example/t/1234. Reply STOP to opt out.",
    "Acme: order #1234 out for delivery today."
  ],

  "message_flow": "Customers tick a clearly-labelled SMS consent box at checkout on acme.example/checkout, beside the text 'Send me order updates by SMS. Msg & data rates may apply. Reply STOP to opt out.' Consent is stored against the order and is not a condition of purchase.",
  "opt_in_keywords": "START, YES",
  "opt_in_message":  "Acme: you're subscribed to order updates. Reply HELP for help, STOP to cancel.",
  "help_keyword":    "HELP",
  "help_message":    "Acme order updates. Support: support@acme.example. Reply STOP to cancel.",
  "stop_keyword":    "STOP",
  "privacy_policy_url": "https://acme.example/privacy",
  "terms_url":          "https://acme.example/sms-terms",
  "embedded_link":  true,
  "embedded_phone": false,
  "age_gated":      false,
  "direct_lending": false
}

message_flow is the single most-read field in a carrier review — the 40-char minimum exists to reject "web form" as an answer. help_keyword is the trigger word; help_message is the sentence sent back. For toll-free, add opt_in_evidence_url pointing at a publicly reachable image or PDF of the consent form: a toll-free verification cannot be approved without evidence.

Campaigns have no PATCH on either surface, so a non-empty provider_missing_fields on a campaign means deleting it and registering again.

// POST /api/v1/phone-numbers
{
  "iso_country": "US",
  "type":        "longcode",
  "campaign_id": "cmp_..."
}

After provisioning, brand/campaign/number all sit in pending until the sms-verify-poll cron picks them up (runs every 5 minutes) and AWS reports COMPLETE/VERIFIED/ACTIVE.

Correcting a brand

// PATCH /api/v1/brands/brd_...
{
  "legal_company_name": "Acme Holdings Pty Ltd",
  "entity_type":        "private_profit"
}

The fields are always stored locally. Whether they also reach the registry is reported by resubmit on the response:

resubmit Meaning
pushed The registry took the new values and the registration was re-submitted. The only outcome in which it saw them.
not_submittable It already has the registration under review or approved, and does not take changes. Delete and register again.
no_registration Nothing is filed yet.
already_verified Approved; nothing to update.
simulated A test-mode brand, or a test-mode key.
busy A background retry held the row. Save again shortly.
unreadable The registry could not be reached, so nothing was filed.

Re-submission goes against the registration already held, so correcting a typo costs nothing at the registry — no second registration is created. A PATCH that did not reach the registry leaves provider_missing_fields exactly as it was, so an unchanged list means the correction is stored but not yet filed.

Test mode

With a sok_test_ key none of this reaches the registry: brands, campaigns and numbers come back verified immediately with test-reg-… identifiers and, for numbers, an unroutable +1555… E.164. Nothing is billed and nothing is orphaned at the provider when your test tears the row down.

Simulated resources stay out of live paths — they cannot back a live registration, be used as a live from, or be added to a messaging pool. And test mode cannot tell you whether a real brand would be approved, or which identity fields the registry would demand: register one with a live key before launch.

Messaging pools themselves are live-only to write. A pool has no environment of its own, and a tenant's pool takes precedence over the org's of the same slug in every from_pool send, so POST /v1/messaging-pools and PATCH / DELETE /v1/messaging-pools/{id} answer 403 ENVIRONMENT_ESCALATION to a test key. Listing and reading pools, and sending with from_pool, stay open to it. Create pools with a live key (write:pools).