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",
"media_url": ["https://example.com/img.png"],
"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" }
}
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[] | URLs, max 10. MMS via AWS.MM.SMS.MediaUrls attribute |
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 template body |
Either from or from_pool is required. Either body or template is required.
Response — 200
{ "id": "msg_...", "channel": "sms", "status": "sent", "created_at": "..." }
Scheduled: same envelope with status: "scheduled" + scheduled_at.
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.
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 after carrier review (minutes-hours for toll-free, hours-days for 10DLC) |
alphanumeric |
UK / AU / most of EU direct-to-carrier | POST /v1/phone-numbers with kind: "alphanumeric", sender_id, iso_country — no AWS round-trip; status verified immediately |
US, CA, and IN reject the alphanumeric path: US/CA require 10DLC or toll-free, IN requires DLT registration (not yet supported).
Errors
| HTTP | Code | When |
|---|---|---|
| 413 | PAYLOAD_TOO_LARGE |
Body > 6MB |
| 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 | TEMPLATE_MISSING_VARIABLES |
Template references vars not supplied |
| 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 - Distinct
fromnumbers looked up once inphone_numbers; live items whosefromisn'tverifiedfail with a per-item error
Each item is sent in parallel under BATCH_SEND_CONCURRENCY=10. Result preserves input order.
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 itemmedia_url— MMS only via single-send
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).
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
SNS delivery logging publishes to a topic that fans out to /api/webhooks/sns-sms. The handler:
- Verifies SNS signature (
Signaturev2 only, 5-minute replay window,sns.*.amazonaws.comcert host) - Enforces
SNS_TOPIC_ARN_ALLOWLISTif set - Rate-limits 600/min per source IP
- For
SubscriptionConfirmation: validatesSubscribeURLhost before confirming - For
Notification: matchesproviderMessageIdon themessagesrow - Maps the AWS status:
SUCCESS/DELIVERED→delivered+message.deliveredBLOCKED/CARRIER_BLOCKED/TTL_EXPIRED/EXPIRED/INVALID_NUMBER/OPTED_OUT→bounced+message.bounced+ auto-suppression- everything else →
failed+message.failed
- Inbound replies: STOP keywords (
STOP,STOPALL,UNSUBSCRIBE,CANCEL,END,QUIT) add a tenant-scoped suppression via the matching prior send. HELP keyword logged for ops visibility; AWS handles the carrier-required response.
See integrations/aws-sns.md for the topic setup.
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 via
AWS.MM.SMS.MediaUrlsrequires AWS End User Messaging in a supported region.
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 — MMS with tags + metadata | media_url, 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 — MMS with metadata
{
"from": "+15555550100",
"to": "+15555550199",
"body": "Receipt attached. Reply STOP to opt out.",
"media_url": ["https://example.com/receipt.png"],
"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_ALREADY_REGISTERED if the same sender_id is already on the org. 422 if iso_country is US/CA/IN.
Brand → Campaign → Phone (10DLC provisioning order)
// POST /api/v1/brands
{
"display_name": "Acme Co",
"ein": "12-3456789",
"vertical": "RETAIL",
"website": "https://acme.example",
"email": "compliance@acme.example"
}
// 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."
]
}
// 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.