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:
- 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. - Calls
CreateRegistrationwith that type. - Pushes each resolved field via
PutRegistrationFieldValue. - Submits the registration version (
SubmitRegistrationVersion). - Stores the
brandsrow 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_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
DescribeRegistrationFieldDefinitionscannot 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 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 inCREATEDorREQUIRES_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.
Consent metadata
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_flowis 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_messageis the HELP reply body.help_keywordis the ≤40-char trigger word. These are different things and both are needed.embedded_link/embedded_phone/age_gated/direct_lendingchange how carriers route and price the campaign.falseis 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 inphone_numberstableSENDER_NOT_VERIFIED— number ispendingorfailed
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),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_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:brandsread:campaigns/write:campaignsread: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_idis 1–11 chars; allowed: letters, digits, space,.,-,_. Carrier filters silently drop anything else.iso_countryinUS/CA/INis rejected with422 VALIDATION_ERROR— those countries require 10DLC, toll-free, or DLT registration respectively.- Duplicate
(orgId, senderId)returns409 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 onphone_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 backverifiedlike a live one, but carries aprovider_number_idstartingtest-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.