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.
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 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 goes through DLT header + template registration instead — see India DLT.
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 | 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 | 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).
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:
BLOCKED/CARRIER_BLOCKED/INVALID_NUMBER/INVALID/OPTED_OUT/SPAMand 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:*_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: 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.- 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.
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.
- 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, …). The digits of a NANP number
genuinely cannot say which of the 25 +1 countries it terminates in, so the
destination resolves to the set of possible countries and is allowed if
any member is on the list. This is deliberate: geo-permissions are a cost
control, not a security boundary — every country on a shared code terminates at
that code's rates, so "might be US, might be Canada" is the same bill either
way, and refusing all +1 traffic because one NANP island is off the list
would make the feature unusable for exactly the orgs it protects.
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).
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_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)
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.