SMS Sender Registration

Required for sending SMS from a non-test number. AWS End User Messaging (Pinpoint SMS-Voice v2) provisions numbers and brokers 10DLC + toll-free verification through The Campaign Registry.

Dashboard: /overview/sms/numbers, /overview/sms/brands, /overview/sms/campaigns. Internal API: /api/internal/{phone-numbers,brands,campaigns}. Public API: /api/v1/{phone-numbers,brands,campaigns}. Provider wrapper: src/lib/providers/sms-registration.ts.

What it costs. On Pro and Pay as you go, numbers ($1.50/month long code or 10DLC, $3.00/month toll-free), 10DLC campaigns ($15/month) and brand registrations ($10 once) are billed for every calendar month they are held in, with no proration — see Billing → Resource charges. Test-key registrations are simulated and never billed.

Lifecycle

brand (pending → verified)
  └── campaign (pending → verified)
        └── phone_number (pending → verified)
                └── /v1/sms accepts the from-number

US 10DLC always follows brand → campaign → number: the number is leased under the approved campaign (NumberType: TEN_DLC with the campaign's RegistrationId), so a US long code cannot exist without one.

Toll-free runs the other way round and has no brand at all:

phone_number (type: tollfree, no campaign)
  └── campaign (kind: tollfree, phone_number_id → the number) = toll-free verification
        └── /v1/sms accepts the number once the verification is verified

AWS will not submit a toll-free verification that names no number (ASSOCIATE_BEFORE_SUBMIT), so the number is leased first and the verification is filed for it.

Teardown runs the other way — number → campaign → brand — and only the brand step enforces it. For the full state machine, which transition emits which event, what each DELETE cascades to, and what a test key does or does not simulate, see Registration Lifecycle, Teardown and Test Mode.

Non-US/CA senders skip the chain entirely. UK / AU / EU support alphanumeric sender IDs, leased with one RequestSenderId call and no brand or campaign; India's sender IDs are DLT headers — see Alphanumeric senders below.

Register a brand

POST /api/internal/brands
{
  "display_name": "Acme, Inc.",
  "legal_company_name": "Acme Holdings Pty Ltd",
  "entity_type": "private_profit",
  "ein": "12-3456789",
  "vertical": "TECHNOLOGY",
  "website": "https://acme.com",
  "email": "ops@acme.com",
  "street": "1 Main St",
  "city": "Melbourne",
  "state_region": "VIC",
  "postal_code": "3000",
  "country": "AU",
  "contact_first_name": "Ada",
  "contact_last_name": "Lovelace",
  "contact_phone": "+61400000000"
}

Only display_name is required by the /v1/brands schema. That is not the same as being enough. TCR vets a brand on its legal identity, so a display-name-only submission is accepted by v1, submitted upstream, and rejected days later with no signal in between. Send the identity block above, and read provider_missing_fields on the response — see Incomplete submissions.

The dashboard twin, POST /api/internal/brands, files live only, so it refuses an incomplete block before the plan cap, the limiter or the registry: 422 BRAND_IDENTITY_INCOMPLETE with fields naming each gap (src/lib/api/live-brand-identity.ts). It requires the legal name, entity type, contact email, registered address (street, city, state/region, postal code, 2-letter country) and the authorized representative (first and last name, E.164 phone); a tax ID (ein, 9 digits for a US entity) and vertical unless the entity is a sole_proprietor; and stock_symbol + stock_exchange for a public_profit. website, when given, must be http(s). The form offers Vertical from the registry's published options (GET /api/internal/sms-registration/fields) and falls back to free text when they cannot be read. PATCH is not held to it, so a partial correction still re-submits.

entity_type is one of private_profit, public_profit, non_profit, government, sole_proprietor, and it determines which other fields the registry insists on (stock_symbol / stock_exchange matter only for public_profit).

Server:

  1. Reads the provider's field definitions for US_TEN_DLC_BRAND_REGISTRATION (DescribeRegistrationFieldDefinitions, memoized 1h) and resolves our field names onto the provider's field paths.
  2. Calls CreateRegistration with that type.
  3. Pushes each resolved field via PutRegistrationFieldValue.
  4. Submits the registration version (SubmitRegistrationVersion).
  5. Stores the brands row with provider registration ID, status, and any required fields the submission left empty.

Returns pending until The Campaign Registry approves (1–3 business days).

With a sok_test_ key none of this happens: the registry is never called, and the brand comes back verified immediately with a test-reg-… provider id. See test mode.

The Free plan holds one live brand per organization (paid plans are billed per registration and are uncapped). The cap is per org, not per tenant — a platform org with many tenants still holds one brand on Free. Every live brand counts whatever it is used for: a brand filed only to carry a toll-free verification's business identity is the same billed registry filing and takes the slot. A toll-free verification needs no brand (see above), so skip the brand for toll-free-only senders. Any status counts — a rejected brand is corrected with PATCH and resubmitted in place, not re-filed — except a failed brand that never reached the registry (no registration id: the filing errored and the reconcile cron gave up), which was never billed, cannot be PATCHed and holds no slot. Test-key brands never count. Free also allows only 3 live brand filings per rolling 30 days, deleted ones included, so file, delete and re-file cannot loop (see billing). Over either the create answers 402 PLAN_RESOURCE_LIMIT before the registry is called or the hourly provisioning allowance is spent; a test key is not held to it.

Field paths are resolved, not hard-coded

The provider's legal field paths (BrandRegistration.TaxId and friends) are not published in its SDK or docs and differ per registration type. Sendoka therefore never hard-codes them: src/lib/providers/registration-fields.ts matches our snake_case names onto whatever the provider currently defines, by normalized leaf name (message_flow → MessageFlow), with an alias list for the cases where the two vocabularies genuinely differ (ein → TaxId).

Consequences worth knowing:

  • A field the provider renames or moves to another section keeps working.
  • A field the provider does not define is reported, not silently dropped.
  • If DescribeRegistrationFieldDefinitions cannot be reached, brand and 10DLC campaign submissions fall back to the previously hard-coded paths so an outage degrades rather than breaks. Toll-free has no fallback — it never had correct literal paths to fall back to.

To see the live contract:

GET /api/internal/sms-registration/fields?kind=TEN_DLC_BRAND

(kind ∈ TEN_DLC_BRAND | TEN_DLC_CAMPAIGN | TOLL_FREE; developer+, 30/min per org.) Returns every field path, its type, whether it is required, its max length, and the legal options for select fields. 503 with available: false means the provider could not be asked.

The dashboard's SMS header reads readiness from:

GET /api/internal/sms-registration/readiness

(any member, project-scoped). It returns { registered_senders, ready, blocked } for the org's live US long codes and toll-free numbers. A number that is not verified is blocked with the guard's SENDER_NOT_VERIFIED message (always refused); a verified one is judged by the sender guard's own carrierRegistrationRefusal, so blocked lists the numbers the carrier-registration rule flags — refused when SMS_CARRIER_GATE=enforce, logged as sms.carrier_gate.would_refuse otherwise. Simulated (test-key) numbers are left out. With no such number the page shows no registration pill at all.

The legacy-path fallback covers a wider case than that: a submission goes out blind whenever the definitions are unreadable or the provider answers with an empty list (an outage, or every entry missing a field path). The second case answers this endpoint 200 with available: true and no fields — so an empty fields array here is the signal, not the status code. A registration submitted blind reports provider_missing_fields: null, never [].

Incomplete submissions

provider_missing_fields on a brand or campaign lists the field paths the provider marks required that the submission had no value for. The meaning is the same for both, but only a brand can be corrected in place — see below:

  • [] — complete.

  • non-empty — accepted upstream but will be rejected. PATCH /api/v1/brands/{id} (or /api/internal/brands/{id}) with the named fields corrects the row and re-submits the registration we already hold, so no second registration is billed. Re-submission reaches the registry only while the registration is in CREATED or REQUIRES_UPDATES; once it is under review or approved the values are stored locally and not pushed, and correcting it means deleting the brand and registering again. This is the signal that used to be a silent multi-day failure.

    Campaigns have no PATCH, on either surface: PATCH /api/internal/campaigns/{id} writes name, description, samples and keywords only, and never calls the provider. A body carrying any consent field (message_flow, opt_in_*, help_message, the policy URLs, the content flags) is refused with 422 naming them, rather than answered 200 with the fields silently dropped. A campaign with a non-empty list has to be deleted and registered again with the named fields supplied.

    A PATCH that did not reach the registry leaves the list untouched, so an unchanged list after a brand PATCH means the correction is stored locally but not yet filed — check provider_status.

  • null — the field definitions were unavailable at submit time (unreachable, or the provider returned no usable definitions), so nothing could be checked. Not the same as []: [] is the positive claim that nothing is missing.

CONDITIONAL fields are deliberately never reported: whether one applies depends on other answers, so flagging them would alarm on every submission.

Register a campaign

brand_id is required for kind: "10dlc", and the brand must be verified. A toll-free campaign takes no brand — see Toll-free verification.

POST /api/internal/campaigns
{
  "brand_id": "brd_...",
  "name": "Order updates",
  "kind": "10dlc",
  "use_case": "delivery_notification",
  "description": "Order shipping and delivery updates for retail customers.",
  "sample_messages": [
    "Hi {{name}} — your order #{{n}} ships today. Reply STOP to opt out."
  ],
  "help_keyword": "HELP",
  "stop_keyword": "STOP",

  "message_flow": "Customers opt in at checkout by ticking an unchecked box labelled 'Text me order updates', next to a link to our SMS terms. Consent is stored against the order.",
  "opt_in_keywords": "START",
  "opt_in_message": "Acme: you're subscribed to order updates. Msg&data rates may apply. Reply HELP for help, STOP to cancel.",
  "help_message": "Acme order updates. Call 1-800-555-0100 or email ops@acme.com. Reply STOP to cancel.",
  "privacy_policy_url": "https://acme.com/privacy",
  "terms_url": "https://acme.com/sms-terms",
  "embedded_link": true,
  "embedded_phone": false,
  "age_gated": false
}

Pricing-relevant: marketing campaigns have higher per-message carrier fees than low_volume or transactional use cases.

The Free plan holds one live 10DLC campaign (paid plans pay its monthly carrier fee and are uncapped). A toll-free verification carries no monthly fee and is not capped — but a brand created to back it still counts against the brand cap above. A failed campaign holds no slot (it is not billed and cannot be resubmitted; file a new one), and test-key campaigns never count. Over the cap the create answers 402 PLAN_RESOURCE_LIMIT before the registry is called; a test key is not held to it.

The block below stop_keyword is what carriers actually review. US 10DLC has required a described opt-in flow since 2026-06-30; a campaign without one is registered and then rejected.

  • message_flow is the load-bearing field — prose describing where consent was captured, what the user saw, and what they agreed to. Minimum 40 characters, deliberately, to reject "web form" as an answer. Carriers read it.
  • help_message is the HELP reply body. help_keyword is the ≤40-char trigger word. These are different things and both are needed.
  • embedded_link / embedded_phone / age_gated / direct_lending change how carriers route and price the campaign. false is a meaningful answer and is submitted; omitting the field submits nothing.

All are optional in the schema so a non-US campaign is not forced to describe a US consent regime. provider_missing_fields reports what the registry actually demanded for the registration you submitted.

The dashboard's Register campaign form (/overview/sms/campaigns) collects the whole block — opt-in workflow, opt-in keywords and confirmation, HELP response and keyword, STOP keyword, privacy/terms URLs, opt-in evidence URL, the four content flags, and up to five sample messages. It used to post none of it, so every dashboard campaign was filed as a billed registration with an empty consent block. The form is stricter than the schema in three places, checked before the billed call (campaign-form.ts): message_flow and help_message are required, and a toll-free campaign requires opt_in_evidence_url.

For Kind: Toll-free verification the form files in the documented order only (number first): it picks the number from the org's toll-free numbers that can take a verification — leased with a live key, not yet bound to a campaign, not failed (the client-side mirror of tollFreeNumberRefusal) — and sends it as phone_number_id. With no such number it links to /overview/sms/numbers; the internal route refuses a toll-free create without one (422 PHONE_NUMBER_REQUIRED), so the legacy campaign-first order is v1-only. The business identity comes either from a brand chosen in Business identity (any brand, verified or not — sent as brand_id) or from the business fields shown when none is chosen (sent as business; only the legal company name is required, and the form checks website, 2-letter country and email formats before the call). No 10DLC brand is needed, so Register campaign is no longer disabled for an org without a verified brand; a 10DLC campaign still requires one.

help_message is not only filed with the registry: it becomes the automatic HELP reply on every live number bound to the campaign — see Number configuration.

Toll-free verification

A kind: "tollfree" campaign is a US toll-free verification (US_TOLL_FREE_REGISTRATION), filed for exactly one toll-free number. It is not a 10DLC registration and needs no brand: requiring one used to force every toll-free customer through a billed 10DLC brand registration they would never use.

  1. Provision the toll-free number first, without campaign_id ({ "iso_country": "US", "type": "tollfree" }).
  2. File the verification naming it:
POST /api/v1/campaigns
{
  "kind": "tollfree",
  "phone_number_id": "pho_...",
  "name": "Account alerts",
  "use_case": "account_notification",
  "description": "Account security alerts for customers who opted in at signup.",
  "sample_messages": ["Acme: your password was changed. Reply STOP to opt out."],
  "message_flow": "Customers opt in on the signup form by ticking an unchecked box ...",
  "opt_in_evidence_url": "https://acme.com/assets/sms-optin-screenshot.png",
  "business": {
    "legal_company_name": "Acme Holdings LLC",
    "website": "https://acme.com",
    "street": "1 Main St",
    "city": "Austin",
    "state_region": "TX",
    "postal_code": "78701",
    "country": "US",
    "contact_first_name": "Ada",
    "contact_last_name": "Lovelace",
    "email": "support@acme.com",
    "contact_phone": "+15125550100",
    "monthly_volume": "10,000"
  }
}

The server creates the registration, puts the fields, associates the number (CreateRegistrationAssociation — AWS refuses to submit a toll-free verification with no number), submits, and binds the number to the campaign in the same transaction as the campaign row. business is required unless a brand_id is given, in which case that brand's identity is used instead (the brand need not be verified, and is never sent as a brand link). The identity used is stored as business_profile so a retry replays exactly what was filed.

Refusals: PHONE_NUMBER_NOT_FOUND / PHONE_NUMBER_NOT_TOLLFREE (422), PHONE_NUMBER_ALREADY_BOUND (409 — one verification per number), PHONE_NUMBER_IS_TEST_MODE (a live key naming a test-key number), PHONE_NUMBER_IS_LIVE_MODE (a test key naming a live number — its simulated, verified campaign would otherwise be bound to the live number).

The "one verification per number" check is a read, so two creates for the same number can both pass it. The binding UPDATE … WHERE campaign_id IS NULL settles it: the loser deletes its own campaign row and the registration it minted (DeleteRegistration; a failure is logged as campaign.create.registration_stranded with the id) and answers 409 PHONE_NUMBER_ALREADY_BOUND.

The number cannot send live until the verification is verified; until then /v1/sms answers 422 SENDER_CAMPAIGN_NOT_APPROVED.

Binding the number re-applies its configuration after the response: the number was leased bare, with the generic HELP reply, and from the bind on its HELP text and custom keywords are the campaign's.

Legacy order: campaign first

Integrations written before toll-free verifications named their number create the campaign first — kind: "tollfree" with no phone_number_id — wait for it to read verified, and only then request the number with { "type": "tollfree", "campaign_id": "cmp_..." }. That order still works, end to end, on the v1 API:

  1. The campaign create leases a US toll-free number itself (held to the plan's number cap, billed like any leased number; simulated for a test key), then files the verification for it and binds it exactly as above. A lease that fails answers 502 PROVIDER_UNAVAILABLE and stores nothing, so the create can be retried.
  2. The number request with that campaign_id returns the number the campaign already holds — 200, nothing leased, not held to the cap.
  3. For a campaign that holds no number (one created by code that predates this), the number request leases one, binds it to the campaign, and files the verification for it (or, if the campaign already holds a registration, associates the number with it). All of it runs under the reconcile cron's claim on the campaign, so two requests (or a request and the cron) never lease two numbers or file it twice: the one that loses gets the winner's number (200), or 503 CAMPAIGN_REGISTRATION_BUSY with Retry-After if it is not bound yet. A filing that does not land is resumed by the cron, which finds the number through the binding.
  4. Such a campaign the client never asks a number for is not left waiting: the reconcile cron leases its number itself (same cap, same claim) and files the verification. Only a lease the plan's number cap refuses retires the campaign (reconcile_state = 'no_number'). A lease refused by Free's rolling 30-day creation budget waits for a later tick instead.

A number Sendoka leased on its own — at step 1 or by the cron — is stored under an id derived from its campaign's (pho_ plus the campaign id's suffix), and goes with the campaign: DELETE /v1/campaigns/{id} (and the dashboard's delete) releases it at AWS and deletes it, as DELETE /v1/phone-numbers/{id} would, instead of only unbinding it; so does a verification refused before it was ever approved (sms-verify-poll) or retired by the reconcile cron. The client may never have learned that number's id. A number the caller leased itself is never released this way — it is only unbound.

The campaign must be kind: "tollfree" (CAMPAIGN_KIND_MISMATCH), not failed (CAMPAIGN_NOT_VERIFIED), and created with a key of the same mode (CAMPAIGN_IS_TEST_MODE / CAMPAIGN_IS_LIVE_MODE); a tenant-bound key sees only its own tenant's campaigns (CAMPAIGN_NOT_FOUND). The dashboard only uses the number-first order and refuses a toll-free campaign with no number (PHONE_NUMBER_REQUIRED). New integrations should use the number-first order above.

Toll-free opt-in evidence

A toll-free verification needs proof of consent, not just a description — opt_in_evidence_url in the body above.

The URL must be publicly reachable. It is fetched and attached to the registration (CreateRegistrationAttachment), and the resulting id comes back as opt_in_evidence_attachment_id. If the upload fails the registration is still submitted — the evidence field will simply appear in provider_missing_fields if the registry required it.

Note that toll-free resolves to registration type US_TOLL_FREE_REGISTRATION, whose field paths differ from 10DLC's (companyInfo.*, contactInfo.*, messageSamples.messageSample1, …). Submitting the same logical body for either kind is correct; resolution handles the difference.

Provision a phone number

POST /api/internal/phone-numbers
{
  "iso_country": "US",
  "type": "longcode",
  "campaign_id": "cmp_..."
}

Server calls RequestPhoneNumber on Pinpoint SMS-Voice v2. AWS allocates the number and returns a phone number ID. Carrier provisioning takes a few minutes; the number's own status flips to verified once AWS reports it active — but see Send-route gating for what a US number additionally needs before it can send.

type accepts longcode, tollfree, or shortcode (refused: AWS cannot lease short codes by API). The rules by country and type:

Number campaign_id Requested as
US longcode required — a verified 10dlc campaign NumberType: TEN_DLC, RegistrationId = the campaign's registration (that is the association)
US tollfree none in the documented order — file a toll-free campaign with phone_number_id afterwards. A tollfree campaign here is the legacy order: its existing number comes back (200), or one is leased and bound to it (v1 only; the dashboard refuses with TOLLFREE_CAMPAIGN_BINDS_NUMBER) TOLL_FREE
CA longcode / tollfree optional, any verified campaign (only tunes MessageType) LONG_CODE / TOLL_FREE

US long-code refusals: CAMPAIGN_REQUIRED, CAMPAIGN_NOT_FOUND, CAMPAIGN_NOT_VERIFIED, CAMPAIGN_KIND_MISMATCH, CAMPAIGN_IS_TEST_MODE, CAMPAIGN_NOT_REGISTERED — all 422, all before anything is leased. Test keys get the same refusals, so a suite that passes against the simulator does not break on the live cutover.

Rebinding a number

PATCH /v1/phone-numbers/{id} with campaign_id:

  • an unbound US long code is associated with the 10DLC campaign at the registry (CreateRegistrationAssociation) before the binding is written; a registry refusal (most often a pre-10DLC LONG_CODE number, which no campaign can take) is 422 ASSOCIATION_REJECTED;
  • if AWS answers that the number is already associated (ConflictException), the binding is written only once DescribePhoneNumbers confirms the association is with this campaign's registration; otherwise 409 ASSOCIATION_CONFLICT;
  • a bound US long code whose association is on record (provider_registration_id) cannot be moved or unbound — 409 NUMBER_CAMPAIGN_LOCKED. AWS has no API to disassociate a number from a 10DLC campaign, so a row that said otherwise would describe a binding the carrier never saw. Release the number and lease a new one;
  • a bound US long code with no association on record — bound before associations were recorded, which AWS holds under nothing — can be associated, including with the campaign it already names (PATCH the same campaign_id). Without this it would be refused at send time and locked here;
  • a US toll-free number is bound only by its verification — 422 TOLLFREE_CAMPAIGN_BINDS_NUMBER;
  • anything else (Canada) is bookkeeping and moves freely.

The plan caps how many numbers an org may lease — Free 1, Pay as you go 10, Pro 25 — counted over live leased numbers (not sender IDs, not test-key numbers). Free also allows only 3 live leases per rolling 30 days, released numbers included, so lease-and-release cannot loop. Over either the create answers 402 PLAN_RESOURCE_LIMIT before anything is leased; a test key is not held to it.

Number configuration (two-way, keywords, opt-out list)

RequestPhoneNumber hands back a bare number: two-way off, AWS's generic HELP text, and the account's Default opt-out list — which every org's numbers share, so a STOP to one customer's number blocked every other customer's sends to that handset. Right after a live number is stored, configureLiveNumber (src/lib/api/sms-number-config.ts) applies:

What AWS call Why
The org's own opt-out list, sendoka-optout-{org_id} CreateOptOutList (idempotent), then UpdatePhoneNumber OptOutListName + SelfManagedOptOutsEnabled: false A STOP blocks that handset for this org only. AWS refuses later sends from any of the org's numbers to it, independently of Sendoka's own suppression row.
Two-way to the SMS topic (SMS_TWO_WAY_TOPIC_ARN) UpdatePhoneNumber TwoWayEnabled + TwoWayChannelArn The only way a reply — STOP, START, HELP, a conversation — reaches /api/webhooks/sns-sms. Skipped (logged) when the env var is unset.
HELP → AUTOMATIC_RESPONSE PutKeyword The bound campaign's help_message verbatim; else "{brand}: for help, contact {email or website}. Msg&data rates may apply. Reply STOP to opt out." naming the campaign's brand (or, for a toll-free campaign filed with only a business_profile, its legal_company_name, email and website), the latest verified live brand the number may carry (its own tenant's, preferred, or an org-level one; never a test-key brand, and never another tenant's), or the org. The campaign's own help_message and keywords apply whichever name is used.
STOP → OPT_OUT, START and UNSTOP → OPT_IN PutKeyword Confirmation texts naming the brand, sent by AWS — your app must not send its own STOP or START confirmation, or the handset gets two. START (or UNSTOP) is what takes a handset back off the org's list at AWS, matching the webhook's START branch that lifts Sendoka's suppression on either word. UNSTOP is best-effort, like the campaign's own words.
The campaign's own help_keyword / stop_keyword words PutKeyword Split on commas, spaces and slashes; each word gets HELP's reply (AUTOMATIC_RESPONSE) or STOP's (OPT_OUT). A word in both fields is an opt-out. Best-effort: a word AWS refuses is logged (sms.number_config.keyword_failed) and the rest still applies.

Moving a number off its old list carries its opt-outs over. AWS honours STOP on its own even with two-way off, so every handset that texted STOP to a number before this existed sits on the shared Default list — and Sendoka never heard about it. Before UpdatePhoneNumber names the org's list, every entry on the number's current list is read (DescribeOptedOutNumbers, all pages) and copied onto the org's list (PutOptedOutNumber). Default cannot say which org a STOP was meant for, so that AWS-side copy over-blocks — deliberately, since message history is redacted after the retention window and cannot prove a STOP was someone else's. Only the entries the org has itself sent a live SMS to (messages.org_id + to_number, asked one AWS page of 100 at a time) are also written as org-wide SMS stop suppressions with source: "aws_opt_out_list"; the rest stay invisible to the org, because on a shared list they are other customers' recipients and a protected stop row could not be deleted. A START from the handset lifts both. If any step fails, the number is not switched and the cron retries; a number whose current list cannot be read is never moved.

The copy is bounded per run, not per org. One run — a whole sms-verify-poll tick, or one provisioning / re-bind call — spends at most 2,000 AWS calls on carry-overs, DescribeOptedOutNumbers pages and PutOptedOutNumber puts together, with at most 10 in flight across every org it reaches. The copy goes a page at a time and stops between pages when the budget runs out; the number stays on its old list and the next run resumes from the saved page token (Upstash, sms:optout-carry:v1:{from}>{to}, 7-day TTL; a token AWS refuses is dropped and the copy restarts). Without Redis every run starts from the top, so a list longer than one run's budget never finishes and the number stays on its old list — the safe direction.

A finished copy leaves a mark (…:done, no TTL) holding when it began. The org's later numbers leaving the same list copy only entries opted out since then (less 15 minutes for clock skew). Re-copying the older ones would re-block a handset that has since sent START to one of the org's own numbers: its Default entry outlives that START. A STOP that lands on a page a copy has already read is caught by the next copy for that org, not by the number that copy switches. Within one tick an org's numbers configured side by side share one copy; a number reached later re-reads the list.

The audit row records both counts: inheritedOptOuts (copied off the old list by that copy) and recordedOptOuts (written as suppressions).

Only production configures numbers. Dev, preview and prod share one database and one AWS account, so every deployment sees the same live numbers. ownsNumberConfig() is true only when VERCEL_ENV=production (or SMS_NUMBER_CONFIG_OWNER=1, for a self-hosted prod); everywhere else configureLiveNumber logs sms.number_config.not_owner and returns skipped, and twoWayChannel() is null whatever SMS_TWO_WAY_TOPIC_ARN says. Without that, a recheck served by a preview would read prod's two-way channel as drift and point the number at the preview's topic, sending its STOPs where prod never sees them.

The list name is derived from the org id, not stored, so there is no migration and nothing to backfill by hand. Where it runs:

  • POST /v1/phone-numbers and POST /api/internal/phone-numbers, after the row is stored (live only), in after() so the 201 does not wait on AWS.
  • PATCH …/phone-numbers/{id} with a campaign_id (bind, re-bind or unbind) — the HELP text follows the campaign — and on an empty-body recheck that finds the number verified.
  • sms-verify-poll: in full when a pending number turns verified, and on every verified number's daily recheck as a drift check — one DescribePhoneNumbers (every configure starts with it); if the number is on another list or two-way is off or pointed elsewhere, the full configuration is applied. This is the lazy backfill for numbers leased before this existed, and the retry for a configure that failed (AWS can refuse an update while a number is PENDING). Keyword text is not compared; a re-bind or a recheck PATCH re-pushes it.

It never fails the caller — the lease is already billing — and logs sms.number_config.failed on error. Each successful apply writes a phone_number.configured audit row. Simulated (test-reg-…) numbers and test keys never reach AWS. Messaging pools are Sendoka-side only (no AWS pool), so the per-number list is the whole of it: a send names the org's own number as OriginationIdentity, and AWS resolves that number's list.

A custom STOP word is opted out at AWS, and the sns-sms webhook treats it as a STOP too: a reply that is exactly one of the stop_keyword words of the campaign bound to the destination number writes the same reason: "stop" suppression and suppression.created event as STOP, and never reaches inbound.sms. Whole-message only — please remove my item is a reply.

Re-check status

PATCH /api/internal/brands/{id}
PATCH /api/internal/campaigns/{id}
PATCH /api/internal/phone-numbers/{id}

Empty body triggers a provider lookup (GetRegistration / DescribePhoneNumbers). Updates local status and emits *.verified / *.unverified audit + webhook on flip.

A brand recheck also accepts a registration still sitting in pending_registration_id — a submission that reached the registry but died before it earned a provider_brand_id. If the lookup finds that registration already submitted, the id graduates and the row leaves the reconcile cron's orphan cohort.

Background poll

Cron /api/cron/sms-verify-poll runs every 5 min (vercel.ts). Three cohorts processed in turn:

  • pending brands < 30 days old
  • pending campaigns < 30 days old
  • pending numbers < 30 days old
  • verified rows past their 24-hour recheck window
  • pending sender IDs < 30 days old (DescribeSenderIds, see below)

The number recheck also records the registration DescribePhoneNumbers reports the number associated with (RegistrationId) into phone_numbers.provider_registration_id. That is the backfill for numbers bound before associations were recorded. Only a reported id is written; an empty answer never clears one.

10DLC reviews can stretch past a week, so the pending cutoff is wider than the email domain cron's 7 days.

Send-route gating

/api/v1/sms rejects sends in live mode unless the from-number exists in phone_numbers with status = "verified". Test mode skips the check so local development can simulate sends with arbitrary numbers.

A US long code or toll-free number additionally needs its carrier registration: campaign_id must name a verified campaign of the matching kind (10dlc for a long code, tollfree for a toll-free number), registered with a live key, and provider_registration_id must equal that campaign's provider_campaign_id — the association has to be on record, not only our binding. It is recorded from a RequestPhoneNumber made with a RegistrationId, a successful (or confirmed) CreateRegistrationAssociation, a toll-free verification submitted with the number attached, or the sms-verify-poll recheck. A number's own verified is AWS's word on the number; a long code with no 10DLC association, or a toll-free number whose verification is still in review, comes back ACTIVE too — and every message from it was blocked or filtered at the carrier while /v1/sms answered sent. The same rule applies to /v1/sms/batch, audience blasts and the scheduled-send cron, and a messaging pool skips members it would refuse rather than picking them.

Rollout switch. The rule is enforced only when SMS_CARRIER_GATE=enforce. Unset (or any other value), every would-be refusal is logged as sms.carrier_gate.would_refuse (with org, from-number and code) and the send goes through, and pools skip nothing. Leave it logging until those lines (and the read-only count in scripts/count-unregistered-us-senders.sql) show who it would hit, and until sms-verify-poll has recorded associations for the existing numbers.

Failure codes:

  • SENDER_NOT_REGISTERED — number not in phone_numbers table
  • SENDER_NOT_VERIFIED — number is pending or failed
  • SENDER_CAMPAIGN_NOT_APPROVED — US number with no approved 10DLC campaign / toll-free verification behind it, or with one but no association on record. Numbers leased before this rule existed (as LONG_CODE, unbound) land here and need replacing under a campaign; a bound one with no association on record can be associated with PATCH campaign_id.

Webhook events

Tenant-scoped, signed with the endpoint secret like all other events:

Event Fires when
brand.verified brand status flips to verified
brand.unverified brand status flips back to pending/failed
brand.removed brand row deleted
campaign.verified campaign status flips to verified
campaign.unverified campaign status flips back to pending/failed
campaign.removed campaign row deleted
phone_number.verified number status flips to verified
phone_number.unverified number status flips back to pending/failed
phone_number.released number released back to provider

Schema

  • brands — id (brd_...), display_name, ein, vertical, website, email, entity_type, legal_company_name, street, city, state_region, postal_code, country, stock_symbol, stock_exchange, contact_first_name, contact_last_name, contact_phone, status, provider_brand_id, provider_status, provider_missing_fields, tenant_id, verified_at, created_at.
  • campaigns — id (cmp_...), brand_id (FK; nullable — a toll-free verification may have none), name, kind, use_case, description, sample_messages, help_keyword, stop_keyword, message_flow, opt_in_keywords, opt_in_message, help_message, privacy_policy_url, terms_url, embedded_link, embedded_phone, age_gated, direct_lending, opt_in_evidence_attachment_id, business_profile (jsonb, toll-free only), status, provider_campaign_id, provider_status, provider_missing_fields, tenant_id, verified_at, created_at.
  • phone_numbers — id (pho_...), kind (number | alphanumeric), e164 (nullable for alphanumeric rows), sender_id (nullable for number rows), type, iso_country, campaign_id (FK, nullable), status, provider_number_id, provider_status, tenant_id, verified_at, created_at. Unique (org_id, e164) AND (org_id, sender_id). Postgres UNIQUE allows multiple NULLs, so each constraint blocks only its own populated column.

Scopes

API keys need explicit scopes for these endpoints:

  • read:brands / write:brands
  • read:campaigns / write:campaigns
  • read:phone_numbers / write:phone_numbers

Existing send:sms scope is unchanged.

AWS prerequisites

The IAM principal behind AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY is the sendoka-app runtime user, and its permissions are checked in at scripts/sendoka-app-runtime-policy.json (SmsRegistrationAndNumbers statement) — that file is the single source of truth. Do not re-derive the list here: an earlier copy in this doc had drifted, listing a sms-voice:GetRegistration that does not exist while omitting CreateRegistrationAttachment and SendMediaMessage, so anyone provisioning from it broke attachment upload and every MMS send. See aws-sns.md § Credentials for how it is applied and recovered.

Pinpoint SMS-Voice v2 must be enabled in AWS_REGION (default us-east-1). The account must be out of the SMS sandbox to send to non-verified recipients in production.

Alphanumeric senders

Use for UK, AU, and most of EU where carriers route SMS via direct-to-carrier with an alphanumeric originator. No brand or campaign registration: a live registration leases the sender ID with RequestSenderId (MessageTypes: TRANSACTIONAL, the type every send carries) and stores its SenderIdArn as provider_number_id; sends then name it as the OriginationIdentity. A country that requires sender-ID registration refuses the lease until that registration is approved — 422 SENDER_ID_REJECTED, carrying AWS's reason, and nothing is stored. A provider outage is 502 PROVIDER_UNAVAILABLE — and so is an IAM denial of sms-voice:RequestSenderId itself (AccessDeniedException … "not authorized to perform"), logged as sms.sender_id.iam_denied: that is the runtime policy (scripts/sendoka-app-runtime-policy.json) not applied to the app's role, which only ops can fix, and it must not read as a permanent refusal of the customer's sender. AWS's own destination refusals keep the 422.

A live sender ID in a country that requires sender-ID registration is stored pending until AWS reports it registered. sms-verify-poll asks every tick for its first 30 days and then once a day for as long as it stays pending — registrations can be approved late, and a sender left pending is refused by the live guard. PATCH /v1/phone-numbers/{id} with an empty body asks AWS right away and flips it to verified (firing phone_number.verified) once it is registered.

Sender IDs are account-wide at AWS: two orgs registering ACME in GB hold the same ARN. A sender ID already in the account is adopted rather than re-requested, and DELETE /v1/phone-numbers/{id} (and the org-delete sweep) only calls ReleaseSenderId once no other row names the ARN.

Rows registered before the lease existed carry a NULL provider_number_id and keep working as before; re-register them to lease the ID.

POST /api/v1/phone-numbers
{
  "kind": "alphanumeric",
  "sender_id": "Acme",
  "iso_country": "GB"
}

Returns { id: "pho_...", kind: "alphanumeric", e164: null, sender_id: "Acme", status: "verified", ... } — usable on the next send:

POST /api/v1/sms
{
  "from": "Acme",
  "to":   "+447700900000",
  "body": "Welcome to Acme!"
}

Constraints

  • sender_id is 1–11 chars; allowed: letters, digits, space, ., -, _. Carrier filters silently drop anything else.
  • iso_country in US / CA is rejected with 422 VALIDATION_ERROR — those countries require 10DLC or toll-free numbers.
  • iso_country: "IN" requires dlt_header_id — see India (DLT) — and dlt_header_id anywhere else is a 422.
  • A sender ID is exclusive to one organization: one another org holds live answers 409 SENDER_ID_UNAVAILABLE, which never names the holder. IDs are compared as a reader sees them, ignoring case, separators and look-alike digits (0/O, 1/I/L, 3/E, 4/A, 5/S, 6/9/G, 7/T, 8/B), so acme, A.C.M.E and ACM3 all collide with ACME. A row another org registered with a test key holds nothing.
  • Reserved names — banks and card networks, carriers, government bodies, major consumer brands, and Sendoka — answer 422 SENDER_ID_RESERVED, whether the ID is the name (HSBC, h.s.b.c, HSBC UK, H5BC) or, for distinctive names, contains it (HSBCALERTS, PAYPA1). Short or ordinary-word names (EE, CITI, APPLE) match only as a whole word or a prefix, so GREEN and CITIZEN are not refused. The list is src/lib/api/reserved-sender-ids.ts. A genuine holder is approved per org by an operator through RESERVED_SENDER_ID_APPROVALS; the approval lifts the screen only, never exclusivity.
  • Both checks run after the DLT header is resolved and before RequestSenderId, for test keys as well as live ones, on the API and the dashboard (SEC-4). They are check-then-insert: two orgs racing for a brand-new ID can both get it, since no cross-org unique index exists yet.
  • Duplicate (orgId, senderId) returns 409 SENDER_ALREADY_REGISTERED, with one exception: a live registration (a live key, or the dashboard) of a sender ID that only a test key registered takes it over. The test-mode row (the one carrying a test-reg-… provider id) is deleted and the live row inserted in one transaction, and the audit entry lists the replaced id under replacedTestRegistrations. One row per sender ID serves both modes, so without this a test key could hold an ID the org needed live. A test key never takes over a live row: it gets the 409.
  • Registrations are capped at 429 PROVISION_LIMIT_EXCEEDED — the same 10/hour/org bucket the number path charges, not a second allowance of its own. A live registration is a provider lease like a number, but it is not billed, and the bucket is the only bound on the row count: the plan's phoneNumbers cap counts leased numbers only, never sender IDs. Test keys bucket separately from live, so CI cannot exhaust the allowance real provisioning depends on.
  • With a sok_test_* key the row is simulated: it comes back verified like a live one, but carries a provider_number_id starting test-reg-, which is what excludes it from live sends and from messaging pools. Register the sender ID again with a live key before sending from it — see Test mode.

Carrier caveats

  • No carrier review step exists for these countries — but recipient carriers may still drop traffic from unrecognized brands. Test against numbers you control before going live.
  • Alphanumeric senders are one-way: recipients can't reply. If you need STOP / replies, port a real E.164 number in (Sendoka doesn't allocate non-US/CA numbers via AWS).
  • UK / AU carrier rules around UTF-8 vs GSM-7 segmenting apply unchanged.

Send gate

/v1/sms POST detects non-E.164 from values and looks up phone_numbers by sender_id instead of e164. The batch route does the same — see api/sms.md for both shapes.

India (DLT)

An India sender ID is a DLT header (dlh_…, registered through the dashboard's /api/internal/dlt/* routes) and nothing else — a sender registered any other way is scrubbed at the carrier. Register it naming the header:

POST /api/v1/phone-numbers
{
  "kind": "alphanumeric",
  "sender_id": "ACMEXX",
  "iso_country": "IN",
  "dlt_header_id": "dlh_..."
}

The header must be this org's — and reachable from the key's project, fenced like the sender guard (its own project, the default project, or unassigned) — verified, under a verified DLT entity that holds a PEID, and sender_id must be the header itself (case-insensitive; the header's own spelling is stored). Refusals: DLT_HEADER_NOT_FOUND, DLT_HEADER_NOT_VERIFIED, DLT_ENTITY_NOT_VERIFIED, DLT_HEADER_MISMATCH (all 422). A live registration then leases the header in IN with RequestSenderId, like any sender ID.

India requires sender-ID registration at AWS, so the lease alone is not permission to send: unless AWS reports the sender ID Registered, the row is stored status: "pending" (no verified_at) and the send gate refuses it with SENDER_NOT_VERIFIED. sms-verify-poll asks DescribeSenderIds for pending sender IDs and flips the row to verified (with the usual phone_number.verified audit and webhook) once AWS reports it registered. Only countries in SENDER_ID_REGISTRATION_COUNTRIES (today: IN) read the flag this way — elsewhere AWS reports Registered: false for sender IDs that send fine.

Every +91 send from it still needs dlt_template_id naming an approved template; the send carries the entity's PEID and the template id as DestinationCountryParameters (IN_ENTITY_ID / IN_TEMPLATE_ID) — see api/sms.md.

Re-submitting an unacknowledged DLT registration

The DLT submit calls swallow a provider failure (a timeout, a 5xx) and store the row as submitted with no provider id. dlt-status-poll can only poll a row that has one, and the entity's PAN and the header are unique per org, so a second POST is a 409. Two ways out (src/lib/api/dlt-resubmit.ts):

  • Scheduled — every dlt-status-poll tick re-submits such rows at fixed points after creation: 30 min, 2 h, 6 h, 24 h, then daily up to day 7. After the last one the row is left alone and dlt.resubmit.exhausted is logged.
  • Manual — POST /api/internal/dlt/resubmit (owner-only, like the DLT POSTs, since each submission can be a billed registration), behind the dashboard's "Retry submission" button on /overview/dlt:
POST /api/internal/dlt/resubmit
{ "id": "dle_..." }

id is a dle_, dlh_ or dlt_ id (422 otherwise). Every attempt, scheduled or manual, first takes a per-row Redis lock one 30-minute window wide (DLT_RESUBMIT_WINDOW_MIN), so a manual retry and a cron tick never both submit. A header has no other guard: with Redis unreachable it is not re-submitted at all.

Status code When
200 — { id, kind, recovered: true } — the provider answered with an id, now stored
502 DLT_PROVIDER_NO_ACK submitted again, and the provider still returned no id. Retried automatically on the schedule
429 DLT_RESUBMIT_TOO_SOON this row was already attempted inside the current 30-minute window; nothing was submitted
404 — no such row in the active org + project
409 DLT_NOT_STRANDED the row already has its provider id, or is no longer submitted
409 DLT_PARENT_PENDING the entity (or, for a template, the entity and header) above it has no provider id yet — retry that one first
503 DLT_RESUBMIT_UNAVAILABLE no DLT provider is configured, or a header could not take its lock (Redis unavailable)

200 and 502 both write a dlt.resubmitted audit row (metadata.outcome). A submission the provider accepted but whose answer was lost to a timeout is not recoverable here: every re-submit is refused as a duplicate and comes back without an id.