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)
phone_numbersrow exists for(orgId, sender)and isverified:- If
fromis E.164 (^\+[1-9]\d{1,14}$), looked up bye164againstkind = 'number'rows - Otherwise (alphanumeric like
Acme), looked up bysender_idagainstkind = 'alphanumeric'rows
- If
- 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 withcode: 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_ALLOWEDwhile the rest of the batch still sends - Distinct
fromnumbers looked up once inphone_numbers; live items whosefromisn'tverifiedfail with a per-item error - Every
dlt_template_idon a+91item is resolved in one query; the DLT verdict is then per item, so one unregistered India recipient fails withDLT_TEMPLATE_REQUIRED/DLT_TEMPLATE_NOT_APPROVEDwhile the rest of the batch still sends. The batch path collapses "not found", "not approved" and "not provisioned" intoDLT_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 bodyscheduled_at/scheduled_local— schedule via single-sendfrom_pool— pass an explicitfromper 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:
- Verifies SNS signature (
Signaturev2 only, one-hour replay window sized to the SNS retry policy,sns.*.amazonaws.comcert host) - Enforces
SNS_TOPIC_ARN_ALLOWLIST— required in production, where an unset value rejects every notification (fails closed); unset outside production skips the check - Rate-limits 600/min per source IP
- For
SubscriptionConfirmation: validatesSubscribeURLhost before confirming - For
Notification: matchesproviderMessageIdon themessagesrow - 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_OUTand theirTEXT_*/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/SPAMand theirTEXT_*/MMS_*twins — an unregistered or flagged sender, not an unreachable handset),*_INVALID_MESSAGE(our body was refused) and theMMS_FILE_*media faults. Note*_INVALIDwithout the suffix is the recipient-side twin and does suppress - In flight —
TEXT_PENDING/TEXT_QUEUED/MMS_PENDINGand friends are acknowledged and ignored. They used to fall intofailed, which is terminal, so the realTEXT_DELIVEREDthat followed could no longer repair the row - An unrecognized token logs
webhook.sns_sms.unmapped_statusand changes nothing, rather than being guessed as a failure
- Delivered —
- 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_smsrow (readable viaGET /v1/inbound) and fires asuppression.createdwebhook — notinbound.sms, see events.md. What counts as an opt-out depends on the word:STOPas the first word —STOP,Stop.,stop texting meall 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,QUITas the whole message — trailing punctuation and whitespace are fine (Cancel.,quit!), anything else is not. These are ordinary English:Can I cancel my 3pm?andwhen does the sale end?used to suppress the handset and withhold the reply frominbound.sms; now they are conversation and flow through the inbound path.- The campaign's own
stop_keywordwords as the whole message — a number bound to a campaign withstop_keyword: "OptOut, Remove"treats a bareremove!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,> STOPand🛑 STOPare a STOP. A real word in front of it is not:Re: STOPandok STOPare stored and delivered asinbound.smsinstead. That is the deliberate cost of the first-word rule — it is what makesplease stop the remindersa sentence — and nothing is dropped, it just arrives as a reply rather than an opt-out. HELP/INFOfollow the whole-message rule for the same reason (I need help with my orderis 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/UNSTOPas the whole message is that opt-in. It removes thereason: "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 anyreason: "bounce"row withsource: "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 assuppression.removedwithsource: "sms_start". The reply then arrives as an ordinaryinbound.smsso your app knows the handset is reachable again. Do not send a confirmation yourself: Sendoka registersSTARTandUNSTOPon 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.YESis not an opt-in keyword — it is how people answer "Reply YES to confirm" — andstart 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 withMMS_INVALID_MEDIA_URI—media_urlreads like it takes one, andSendMediaMessagedoes 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/smsanswers422 MMS_NOT_SUPPORTED(until 2026-11 it was a generic500, which the SDKs retried); a batch item fails asPROVIDER_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
SMSTypeexists 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.SMSTypeis alwaysTransactionalon direct publish. Promotional traffic should ride through a 10DLC campaign provisioned withMessageType: PROMOTIONAL(see features/sms-registration.md).AWS.SNS.SMS.SenderIDis only set whenfromis 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
- Postman → File → Import → drop the JSON
- 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 sendsfromNumber/toNumber→ defaults are+15555550100/+15555550199
- 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).