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 numberCallbacks delivered (in order)Terminal statuserror_code on failed
+15005550101message.queued → message.sent → message.delivereddelivered—
+15005550102message.queued → message.sent → message.failedfaileddelivery_failed
+15005550103message.failedfailedinvalid_recipient
+15005550104message.queued → message.sent → message.delivered → message.opt_outdelivered—
+15005550105message.failedfailedLANDLINE
+15005550106message.queued → message.sent → message.delivereddelivered—

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.sent with no separate message.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_id is stable across retries — assert your handler processes each event_id once.
  • metadata / client_reference echo: if you attach metadata or client_reference on 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.

  1. Create two status-callback destinations (e.g. one per program), each with its own secret.
  2. Create two test numbers with POST /api/v1/phone-numbers (test key; reserved range +15005550200–0299).
  3. Assign each number its own destination: PATCH /api/v1/phone-numbers/{id} with status_callback_destination_id.
  4. 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


Did this page help you?