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.
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.",
"ein": "12-3456789",
"vertical": "TECHNOLOGY",
"website": "https://acme.com",
"email": "ops@acme.com"
}
Server:
- Calls
CreateRegistrationon Pinpoint SMS-Voice v2 with typeTEN_DLC_BRAND_REGISTRATION. - Pushes each field via
PutRegistrationFieldValue. - Submits the registration version (
SubmitRegistrationVersion). - Stores
brandsrow with provider registration ID + status.
Returns pending until The Campaign Registry approves (1–3 business days).
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"
}
Pricing-relevant: marketing campaigns have higher per-message carrier fees
than low_volume or transactional use cases.
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.
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,status,provider_brand_id,provider_status,tenant_id,verified_at,created_at.campaigns—id (cmp_...),brand_id(FK),name,kind,use_case,description,sample_messages,help_keyword,stop_keyword,status,provider_campaign_id,provider_status,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 needs:
sms-voice:RequestPhoneNumbersms-voice:DescribePhoneNumberssms-voice:ReleasePhoneNumbersms-voice:CreateRegistrationsms-voice:GetRegistrationsms-voice:DeleteRegistrationsms-voice:PutRegistrationFieldValuesms-voice:SubmitRegistrationVersion
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.
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.