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:

  1. Calls CreateRegistration on Pinpoint SMS-Voice v2 with type TEN_DLC_BRAND_REGISTRATION.
  2. Pushes each field via PutRegistrationFieldValue.
  3. Submits the registration version (SubmitRegistrationVersion).
  4. Stores brands row 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 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, status, provider_brand_id, provider_status, tenant_id, verified_at, created_at.
  • campaignsid (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_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 needs:

  • sms-voice:RequestPhoneNumber
  • sms-voice:DescribePhoneNumbers
  • sms-voice:ReleasePhoneNumber
  • sms-voice:CreateRegistration
  • sms-voice:GetRegistration
  • sms-voice:DeleteRegistration
  • sms-voice:PutRegistrationFieldValue
  • sms-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_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.

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.