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:

  • verifiedCOMPLETE, ACTIVE, PROVISIONED, VERIFIED, APPROVED, REGISTERED
  • failedDELETED, 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; null means the field definitions were unreadable, so nothing is known. The three are not interchangeable, and null is not [].
  • reconcile_state — why the reconcile cron gave up: age_cap or attempt_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:

  1. pending → failed emits 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 becomes failed. If you are waiting purely on webhooks, you wait forever.
  2. A pending row older than 30 days stops being polled. The pending cohort is bounded (created_at > now() - 30 days); past that the row stays pending and 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} Yes409 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: null that your records say should be pinned (the campaign-delete case above);
  • a campaign whose brand_id names a brand that no longer appears in GET /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.