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.

Lifecycle

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

US 10DLC always follows brand → campaign → number. Toll-free skips the brand step but still requires campaign-style verification.

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 that don't require AWS provisioning — 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 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 here, 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.

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

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_flowMessageFlow), with an alias list for the cases where the two vocabularies genuinely differ (einTaxId).

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

(kindTEN_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 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 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 must be verified for kind: "10dlc". Toll-free skips this gate.

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

Toll-free opt-in evidence

A toll-free verification needs proof of consent, not just a description:

POST /api/internal/campaigns
{
  "kind": "tollfree",
  "opt_in_evidence_url": "https://acme.com/assets/sms-optin-screenshot.png",
  ...
}

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 TOLL_FREE, whose field paths differ from 10DLC's. Submitting the same logical body for either kind is correct; resolution handles the difference.

Provision a phone number

Campaign must be verified to bind a number. Numbers can be provisioned unbound and attached to a campaign later via PATCH.

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; status flips to verified when the number is sendable.

type accepts longcode (10DLC), tollfree, or shortcode.

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

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.

Failure codes:

  • SENDER_NOT_REGISTERED — number not in phone_numbers table
  • SENDER_NOT_VERIFIED — number is pending or failed

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

  • brandsid (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.
  • campaignsid (cmp_...), brand_id (FK), 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, status, provider_campaign_id, provider_status, provider_missing_fields, tenant_id, verified_at, created_at.
  • phone_numbersid (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 AWS provisioning step, no brand or campaign registration — Sendoka records the sender ID locally and uses it as AWS.SNS.SMS.SenderID at publish time.

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 / IN is rejected with 422 VALIDATION_ERROR — those countries require 10DLC, toll-free, or DLT registration respectively.
  • Duplicate (orgId, senderId) returns 409 SENDER_ALREADY_REGISTERED.
  • 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. Nothing is billed here, but the bucket is the only bound on the row count: there is no plan cap on phone_numbers. 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.