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:
- 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. - 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.
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
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 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 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 body carrying any consent field (message_flow,opt_in_*,help_message, the policy URLs, the content flags) is refused with422naming them, rather than answered200with 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.
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.
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.
- Provision the toll-free number first, without
campaign_id({ "iso_country": "US", "type": "tollfree" }). - 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:
- 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_UNAVAILABLEand stores nothing, so the create can be retried. - The number request with that
campaign_idreturns the number the campaign already holds —200, nothing leased, not held to the cap. - 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), or503 CAMPAIGN_REGISTRATION_BUSYwithRetry-Afterif it is not bound yet. A filing that does not land is resumed by the cron, which finds the number through the binding. - 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-10DLCLONG_CODEnumber, which no campaign can take) is422 ASSOCIATION_REJECTED; - if AWS answers that the number is already associated (
ConflictException), the binding is written only onceDescribePhoneNumbersconfirms the association is with this campaign's registration; otherwise409 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 (
PATCHthe samecampaign_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-numbersandPOST /api/internal/phone-numbers, after the row is stored (live only), inafter()so the 201 does not wait on AWS.PATCH …/phone-numbers/{id}with acampaign_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 — oneDescribePhoneNumbers(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 isPENDING). 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 inphone_numberstableSENDER_NOT_VERIFIED— number ispendingorfailedSENDER_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 (asLONG_CODE, unbound) land here and need replacing under a campaign; a bound one with no association on record can be associated withPATCH 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: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 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_idis 1–11 chars; allowed: letters, digits, space,.,-,_. Carrier filters silently drop anything else.iso_countryinUS/CAis rejected with422 VALIDATION_ERROR— those countries require 10DLC or toll-free numbers.iso_country: "IN"requiresdlt_header_id— see India (DLT) — anddlt_header_idanywhere else is a422.- 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), soacme,A.C.M.EandACM3all collide withACME. 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, soGREENandCITIZENare not refused. The list issrc/lib/api/reserved-sender-ids.ts. A genuine holder is approved per org by an operator throughRESERVED_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)returns409 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 atest-reg-…provider id) is deleted and the live row inserted in one transaction, and the audit entry lists the replaced id underreplacedTestRegistrations. 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 the409. - 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'sphoneNumberscap 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 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.
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.exhaustedis 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.