Registration Lifecycle, Teardown and Test Mode
Companion to SMS Sender Registration, which covers how to submit a brand, campaign or number. This page covers everything after that: what the states mean, which transitions are legal, which events each one emits, what a delete actually does, and what a test key does or does not simulate.
Written for the integrator holding a webhook handler and a teardown path, both of which have to decide things the submission guide never states.
Sources: src/app/api/cron/sms-verify-poll/route.ts (the only writer of
verified/unverified transitions), src/app/api/cron/reconcile-registrations/route.ts,
src/lib/providers/sms-registration.ts (mapStatus, the simulators), and the
DELETE handlers under src/app/api/v1/{brands,campaigns,phone-numbers}/[id]/.
The two status fields are not the same field
| Field | Who writes it | What it means |
|---|---|---|
status |
Us | pending | verified | failed. The collapsed state the whole product keys off — send gating, dashboards, webhooks. |
provider_status |
AWS, verbatim | The registry's own ladder: CREATED, SUBMITTED, REVIEWING, COMPLETE, REQUIRES_UPDATES, REJECTED, … Mirrored without interpretation. |
status is derived from provider_status by mapStatus:
- verified ←
COMPLETE,ACTIVE,PROVISIONED,VERIFIED,APPROVED,REGISTERED - failed ←
DELETED,REJECTED,CLOSED,DENIED,REQUIRES_AUTHENTICATION,REVOKED - pending ← everything else, including a null status
Branch on status. Read provider_status when you need to explain why to a
human — it is the only field carrying the registry's actual reason, and new
values appear there without warning, which is exactly why they all collapse to
pending rather than to an error.
Two more fields carry state that status cannot:
provider_missing_fields—[]means the submission was complete; non-empty means it was accepted but the registry will reject it, and the array names the field paths;nullmeans the field definitions were unreadable, so nothing is known. The three are not interchangeable, andnullis not[].reconcile_state— why the reconcile cron gave up:age_cap,attempt_cap, or (campaigns only)no_number— a toll-free campaign with no bound toll-free number for which the cron could not lease one either (the plan's number cap refused it), which AWS will never submit (any registration an earlier attempt minted is deleted at AWS as it is retired). Never an AWS status.
State machine
Identical for brands, campaigns and phone numbers.
┌──────────────────────────────────────┐
POST /v1/… │ │
│ │ *.verified │
▼ │ ┌──────────────┐ │
┌─────────┐ └──────│ │ │
│ pending │─────────────▶│ verified │ │
└─────────┘ │ │ │
│ ▲ └──────────────┘ │
│ │ │ │ │
│ └──────────────────┘ │ *.unverified │
│ *.unverified ▼ │
│ ┌────────┐ │
└────────────────────────────▶│ failed │◀─────────────┘
(no event) └────────┘
terminal
Every transition above is written by /api/cron/sms-verify-poll, which runs
every 5 minutes and polls the provider. Nothing else moves a row between states.
| From | To | Event | Notes |
|---|---|---|---|
| — | pending |
none | Live create. The 201 is a submission, not an approval. |
| — | verified |
none | Test-mode create only — simulated, immediate, no event. |
pending |
verified |
*.verified |
The happy path. Approval can take days to weeks. |
pending |
failed |
none | Rejected before ever being approved. See the gotcha below. |
verified |
pending |
*.unverified |
A carrier pull that put the registration back under review. |
verified |
failed |
*.unverified |
A carrier pull that revoked it outright. |
failed |
anything | none | Terminal. Nothing polls a failed row again. |
| any | (row gone) | *.removed / phone_number.released |
Only ever your own DELETE. |
unverified after verified is a carrier pull, always
The poll emits *.unverified on exactly one condition: the row was verified
and the provider now reports something that is not. It is never a late duplicate
of an earlier event and never a re-delivery — the fan-out is gated on an atomic
row claim (UPDATE … WHERE status = <the status we read>), so overlapping cron
runs cannot both announce the same flip.
So an unverified on a sender you did not touch means the carrier revoked or
re-opened it. Treat it as a live outage of that sender: stop sending, tell the
merchant, and check provider_status for the reason.
Two ways a registration goes quiet without an event
Both are real, both have bitten integrations, and neither produces a webhook:
pending → failedemits nothing. The event is named unverified and fires only on the loss of a verification that existed. A submission rejected before it was ever approved just becomesfailed. If you are waiting purely on webhooks, you wait forever.- A
pendingrow older than 30 days stops being polled. The pending cohort is bounded (created_at > now() - 30 days); past that the row stayspendingand is never checked again. 10DLC reviews genuinely can run for weeks, which is why the window is wide, but it is a window.
Poll GET /v1/{brands,campaigns,phone-numbers} as well as subscribing.
Webhooks cover the transitions that happen after an approval; they do not cover
never being approved. A daily sweep of anything still pending closes both gaps.
Recheck cadence
A verified row is re-polled once its verified_at is more than 24 hours old,
which is what makes revocations visible at all. Steady state is therefore one
provider round trip per row per day, and a revocation surfaces within ~24h of
the registry publishing it — not instantly.
Teardown
Delete is the only teardown, and the three endpoints do not behave alike.
| Endpoint | Refuses when children exist? | Effect on children | Reversible |
|---|---|---|---|
DELETE /v1/brands/{id} |
Yes — 409 BRAND_HAS_CAMPAIGNS |
— | No |
DELETE /v1/campaigns/{id} |
No | Attached numbers are detached, not released | No |
DELETE /v1/phone-numbers/{id} |
No | inbound_sms rows keep their to_number but lose the link (phone_number_id → null); pool membership is pruned |
No |
The number delete is ordered so nothing irreversible happens while the local
delete could still fail: pool membership is pruned first, the number is
released at the provider second, and the row is deleted last — unlinking the
inbound_sms rows in the same transaction, so a reply that lands at any point
before the commit cannot block it. (It used to release first, and a single reply
on the number made the delete fail on the FK after the lease was already gone: a
500 and a verified row nothing could remove.) A release that fails at the
provider is logged and the row is still deleted.
One window remains and is not closable from here: if the provider release succeeds and the final transaction then fails, the request answers 500 with the lease gone and the row still present. It differs from the old failure in the way that matters — nothing references the row any more, so retrying the same DELETE removes it (the second release attempt fails at the provider, is logged, and is ignored).
Order: number → campaign → brand
That order is mandatory, but only one step enforces it. Deleting a brand with campaigns still attached is refused; deleting a campaign with numbers still attached is not. So the chain will happily half-collapse if you work top-down.
Deleting a campaign strands its numbers
This is the one that costs money. DELETE /campaigns/{id} sets campaign_id to
null on every number pinned to it and then deletes the campaign. Those numbers:
- still exist, still hold their provider registration, and still bill;
- emit no event — there is no
phone_number.unpinned; - are no longer reachable by "numbers under this campaign", because that link is what was just erased.
Release each number before deleting its campaign, or reconcile afterwards (below). A best-effort teardown that swallows errors and continues is the exact shape that leaks here: the number release fails, the campaign delete succeeds, and the only pointer to the orphan is gone.
Releases are immediate and permanent
There is no grace period and no reclaim. Re-provisioning after a release
allocates a different number — the API offers no way to request a specific
one (ProvisionPhoneNumber accepts iso_country, type, campaign_id and
tenant_id, and nothing else). Merchant-facing copy should say so plainly.
A failed provider release does not fail the call
Each delete tries to release the underlying registration at the provider on the
way out. If that call throws it is logged and not retried — the local row is
deleted either way, and the log line is the only surviving record of what to
clean up by hand. A 200 from a delete means our row is gone; it does not prove
the provider-side resource is.
Reconciliation
Because teardown is multi-step, non-transactional and partly silent, the list endpoints are the source of truth for what you actually own. Diff them against your own records on a schedule — daily is plenty.
An orphan is any of:
- a phone number with
campaign_id: nullthat your records say should be pinned (the campaign-delete case above); - a campaign whose
brand_idnames a brand that no longer appears inGET /v1/brands; - any registration resource your side has no record of at all, usually the remains of a teardown that failed partway.
# Everything we hold, per resource. Page with ?cursor= until has_more is false.
curl -s "$BASE/v1/phone-numbers?limit=100" -H "Authorization: Bearer $SENDOKA_API_KEY"
curl -s "$BASE/v1/campaigns?limit=100" -H "Authorization: Bearer $SENDOKA_API_KEY"
curl -s "$BASE/v1/brands?limit=100" -H "Authorization: Bearer $SENDOKA_API_KEY"
Then, for each orphan, tear down in dependency order: release the number, delete
the campaign, delete the brand. A brand delete that answers 409 means a
campaign you have not accounted for is still attached — list campaigns again
rather than forcing it.
Orphans keep costing. A registered brand, an approved campaign and a provisioned number each carry provider and carrier charges that continue until the resource is released, regardless of whether anything still points at it. A reaper that never runs is a bill that never stops.
Test mode
sok_test_* keys and sok_live_* keys hit the same routes and the same tables.
There is no environment column on brands, campaigns, phone_numbers or
messaging_pools — simulated and live resources sit in the same rows, and the
send guards exclude simulated ones explicitly.
What a test key does depends entirely on the capability area:
| Area | With a test key |
|---|---|
| Brand / campaign registration | Fully simulated. No provider call, nothing billed. The row is created already verified, with a provider_status of COMPLETE and a provider id starting test-reg-. |
| Phone number provisioning | Simulated. Returns a number in the permanently-unassignable +1555… range, status: verified. Short codes are still refused, because live refuses them — test mode must not pass what production cannot. For the same reason the registration rules apply unchanged: a US long code still needs a verified 10dlc campaign_id (CAMPAIGN_REQUIRED), and a US toll-free number still takes none in the number-first order. A toll-free campaign naming a simulated number is simulated too, and one created with no number (the legacy order) gets a simulated number leased and bound to it, which a later number request with its campaign_id returns. |
| Alphanumeric sender registration | Simulated — no RequestSenderId — and the row itself is what would otherwise leak. It lands verified with a provider_number_id starting test-reg- and a provider_status of ACTIVE, which is what puts it on the excluded side of the send guards; e164 stays null, as it does for a live sender ID. A sender ID registered with a sok_test_* key is therefore not usable by a live send, and cannot be added to a messaging pool. Register it again with a live key to send from it: the live registration replaces the test-mode row (it used to answer 409 SENDER_ALREADY_REGISTERED, so a test key could hold a sender ID the org needed live). A test key cannot take over a live row. |
| Email / SMS sends | Accepted, recorded, and metered — never handed to a provider. The message row lands as sent. Sender-ownership isolation is still enforced; warmup caps and IP-pool routing are skipped. |
| OTP verifications | Same: recorded, never delivered. |
| Domains | Not simulated. A test key creates a real SES identity and returns real DKIM tokens. |
| Webhooks | Real. Test-mode activity fans out real signed deliveries to your endpoints. |
| Usage | Metered separately from live — test sends never touch your live counters or bill. |
What test mode cannot rehearse
Because a simulated registration is born verified, there is no pending
period, no poll, and therefore no *.verified webhook and no way to reach
failed. The state machine at the top of this page is exercised only by live
registrations.
To exercise a webhook handler's registration paths without spending money on a
10DLC chain, use POST /v1/webhooks/{id}/test-fire, which delivers a fixture
payload signed with the endpoint's real secret.
Historical note
Test keys did not always behave this way. POST /v1/{brands,campaigns,phone-numbers}
once ignored the key's environment entirely, so a CI suite pointed at a
sok_test_* key paid for a real, billed 10DLC registration on every run —
CreateRegistration and RequestPhoneNumber are both billed and
non-idempotent. The full entry lives in the team's internal gap register, which
is not published here.
Related
- SMS Sender Registration — how to submit each resource
- Webhooks · Event catalog
- Idempotency — the registration POSTs accept
Idempotency-Key, and a reused key with a changed body is a non-transient 409