Testing status callbacks
Drive every delivery-status event with magic numbers and verify your status-callback receiver end-to-end in test mode — no carrier, real signatures.
This guide shows how to verify your status-callback receiver end-to-end in test mode: every lifecycle event, the failure paths, signature verification, and per-number routing — all without sending real SMS. It builds on test keys & magic numbers (issue a test key first) and the status-callbacks reference (payload, signing, retries).
In test mode nothing reaches a carrier and nothing is billed, but the status-callback machinery fires for real: the same message.<event> payloads, HMAC signing, retry schedule, and idempotency as a live send.
1. Point a destination at your receiver
Create a status-callback destination (name + HTTPS URL) and set it as your workspace default, or pass a per-send status_callback URL. Capture the signing secret returned once on create (generated_secret) — you'll verify against it.
For local development your receiver must be reachable at a public HTTPS URL (the destination URL is validated against SSRF). A tunnel (e.g. cloudflared, ngrok) or a request-capture service works.
2. Drive every lifecycle event
Send to a magic number (see magic numbers) to select a deterministic lifecycle. Each row below is the exact sequence of signed callbacks your receiver should get, so you can use it as a checklist:
| Magic number | Callbacks delivered (in order) | Terminal status | error_code on failed |
|---|---|---|---|
+15005550101 | message.queued → message.sent → message.delivered | delivered | — |
+15005550102 | message.queued → message.sent → message.failed | failed | delivery_failed |
+15005550103 | message.failed | failed | invalid_recipient |
+15005550104 | message.queued → message.sent → message.delivered → message.opt_out | delivered | — |
+15005550105 | message.failed | failed | LANDLINE |
+15005550106 | message.queued → message.sent → message.delivered | delivered | — |
Any other well-formed E.164 number on a test key takes the success path (queued → sent → delivered).
These sequences are deterministic in test mode — the simulator drives the full lifecycle. Live sends are carrier-driven and may differ: a message that hands off immediately can go straight to
message.sentwith no separatemessage.queued. Process the events you receive idempotently rather than expecting a fixed sequence.
Pre-send failures arrive as callbacks too — +15005550103 (hard reject) and +15005550105 (landline) emit a single message.failed with no prior queued/sent, exactly as a real pre-send rejection (opted-out recipient, landline, missing provider config) would. You never poll to discover these.
The send acknowledgment always reports a non-terminal queued; the terminal status (delivered/failed) lands on the message and is visible via GET /api/v1/messages/{id} and the final callback.
3. Verify each callback
Every callback carries X-Beep-Signature: sha256=<hmac> and X-Beep-Timestamp, signed with the secret of the destination that resolved for that message (see resolution order). Verify it with that destination's secret using the signature-verification steps. Two checks worth automating in your tests:
- Idempotency:
event_idis stable across retries — assert your handler processes eachevent_idonce. metadata/client_referenceecho: if you attachmetadataorclient_referenceon the send, assert they come back unchanged on every event for that message.
4. Test per-number routing — with no carrier numbers
You can validate that each sending number reports to its own destination, signed with its own secret, before provisioning any real numbers — using test numbers.
- Create two status-callback destinations (e.g. one per program), each with its own secret.
- Create two test numbers with
POST /api/v1/phone-numbers(test key; reserved range+15005550200–0299). - Assign each number its own destination:
PATCH /api/v1/phone-numbers/{id}withstatus_callback_destination_id. - Send from each test number (
from) to+15005550101(or any magic number).
Each number's callbacks arrive at its destination, signed with its secret — so you can confirm two programs sharing one workspace stay cleanly separated. The sibling flow for inbound replies is in Testing senders & reply routing.
Try it
# Send to the success number; callbacks go to your workspace-default destination
# (or the per-send URL below), signed and retried like production.
curl -X POST https://app.beepmessaging.com/api/v1/messages \
-H "Authorization: Bearer beep_test_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"status_callback": "https://app.example.com/hooks/beep-status",
"messages": [{ "to": "+15005550101", "body": "Status callback test." }]
}'Swap to for +15005550102 (delivery failure), +15005550103 (hard reject), +15005550104 (opt-out), or +15005550105 (landline) to exercise each lifecycle and confirm your receiver handles the failed/opt_out paths and their error_codes.
Related
- Status callbacks — payload, resolution order, signature verification
- Testing with test keys & magic numbers — the test-key + magic-number basics
- Testing senders & reply routing — the inbound sibling of per-number routing
- Opt-outs & consent — how
STOPsurfaces asmessage.opt_out
Updated 3 months ago