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.

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 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).


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-smsnot 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:
    • DeliveredSUCCESS / DELIVERED / TEXT_DELIVERED / TEXT_SUCCESSFUL / MMS_DELIVERED / MMS_SUCCESSFULdelivered + message.delivered
    • Bounced + auto-suppressed — only tokens meaning the recipient is permanently unreachable: BLOCKED / CARRIER_BLOCKED / INVALID_NUMBER / INVALID / OPTED_OUT / SPAM and their TEXT_* / MMS_* twins
    • Failed, no suppressionFAILURE, 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 the MMS_FILE_* media faults. Note *_INVALID without the suffix is the recipient-side twin and does suppress
    • In flightTEXT_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: 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 wordSTOP, 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.
    • 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.

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_URImedia_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.
  • 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, …). 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.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)
    • 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 — 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.