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)

  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.

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 with code: SUPPRESSED
  • Distinct from numbers looked up once in phone_numbers; live items whose from isn't verified fail 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 body
  • scheduled_at / scheduled_local — schedule via single-send
  • from_pool — pass an explicit from per item
  • media_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:

  1. Verifies SNS signature (Signature v2 only, 5-minute replay window, sns.*.amazonaws.com cert host)
  2. Enforces SNS_TOPIC_ARN_ALLOWLIST if set
  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:
    • SUCCESS / DELIVEREDdelivered + message.delivered
    • BLOCKED / CARRIER_BLOCKED / TTL_EXPIRED / EXPIRED / INVALID_NUMBER / OPTED_OUTbounced + message.bounced + auto-suppression
    • everything else → failed + message.failed
  7. 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.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 via AWS.MM.SMS.MediaUrls requires 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

  1. Postman → File → Import → drop the JSON
  2. Open the collection's Variables tab:
    • baseUrl → your deploy (http://localhost:3000, https://YOUR-HOST)
    • apiKeysok_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 — 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.