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_caporattempt_cap. 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 | — | No |
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. |
| 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. See Open Gaps for the full entry
and for anything else still outstanding.
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 - Open Gaps