Status callbacks
Subscribe to delivery-status events (queued, sent, delivered, failed, opt-out) for the messages you send, with signed, retried webhooks.
Status callbacks are how Beep reports the lifecycle of the messages you send. When a message is queued, hands off to the carrier, gets delivered, fails, or triggers an opt-out, Beep POSTs a small signed JSON event to a URL you control. This is the sender-facing side of the API — distinct from inbound replies, which is where messages from your recipients go.
Configure a destination (recommended)
A status-callback destination is a named webhook target — a URL plus its own signing secret — that you create once and reuse. It is the recommended way to receive status callbacks. With destinations you can:
- set one as your workspace default — the catch-all every message reports to. For a single-endpoint integration this is all you need: create one destination, set it as the workspace default, done.
- assign others as per-number defaults — so messages sent from different numbers (for example, different programs) report to different endpoints, each verified with its own secret, with nothing to pass per request.
Each destination carries its own signing secret, so different programs can verify with different keys.
Manage destinations
Dashboard: Workspace Settings → Webhooks → Status callbacks. Add a destination (name + HTTPS URL), set its secret (or click Generate to have Beep create one and show it once), then set it as the workspace default there, and/or assign it to a number in Settings → SMS.
API (manager- or admin-role key):
GET/POST /api/v1/status-callback-destinations— list and create destinations. Providesecretto set your own, orgenerate_secret: trueto have Beep return a strong one once asgenerated_secret. The secret is never returned again — reads expose onlysecret_set: true.GET/PUT/DELETE /api/v1/status-callback-destinations/{id}— read, update (including rotating the secret), or delete. A delete is blocked (400 validation_error) while the destination is referenced as a workspace or per-number default — clear the reference first.GET /api/v1/phone-numbers— list your numbers and theirids.PATCH /api/v1/phone-numbers/{id}— assign a per-number default (status_callback_destination_id, and/orreply_destination_id) to a number.PUT /api/v1/notification-settings— set the workspace default destination (default_status_callback_id).
Set the workspace default and assign per-number defaults from the dashboard (Settings → Webhooks and Settings → SMS) or entirely over the API with the endpoints above. See the API Reference for full request and response shapes.
Example: route a number to its own endpoint
# 1. Create a destination — Beep generates and returns the signing secret once.
curl -X POST https://api.beepmessaging.com/api/v1/status-callback-destinations \
-H "Authorization: Bearer beep_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Program A", "url": "https://app.example.com/hooks/program-a", "generate_secret": true }'
# -> { "data": { "id": "…", "name": "Program A", "secret_set": true, "generated_secret": "…store this now…" } }
# 2. Assign it as the default for a sending number (get the id from GET /api/v1/phone-numbers).
curl -X PATCH https://api.beepmessaging.com/api/v1/phone-numbers/<phone_number_id> \
-H "Authorization: Bearer beep_live_..." \
-H "Content-Type: application/json" \
-d '{ "status_callback_destination_id": "…" }'Messages sent from that number now report to Program A's endpoint, signed with Program A's secret.
Resolution order
For each message, Beep picks the destination URL and signing secret in this order:
- Per-send
status_callback(a URL in a singlePOST /api/v1/messagesbody) — signed with the workspace secret. See Overriding the URL for a single send. - The per-number default of the sending number — signed with that destination's own secret.
- The workspace default destination — signed with its own secret.
- The legacy inline workspace URL + secret, if you haven't moved to a destination (see Legacy: single workspace URL).
Inactive or deleted destinations are skipped, and resolution falls through to the next step. If nothing resolves, no callback is sent.
| Destination URL | Signed with | |
|---|---|---|
Per-request status_callback | The URL in a single POST /api/v1/messages body | Your workspace secret |
| Per-number default | The destination assigned to the sending number | That destination's secret |
| Workspace default (catch-all) | The destination set as the workspace default | That destination's secret |
Overriding the URL for a single send
You can optionally send the status_callback field on POST /api/v1/messages to route that send's callbacks to a different URL — for example, a dedicated endpoint for one batch. It takes precedence over any per-number or workspace default. The override only changes the destination URL; callbacks are still signed with your workspace secret, so they remain verifiable.
The one case where callbacks are unsigned: you use the per-send override (or a legacy inline URL) but have never configured a workspace secret. Always set a secret so every callback can be verified.
Legacy: single workspace URL
Being phased out. Setting a single status-callback URL + secret directly on the workspace is the original setup, from before named destinations. It still works and is fully supported for now, but new integrations should use a destination (above) — set one as your workspace default for the same one-endpoint simplicity, with the option to add per-number routing later without re-plumbing.
Set the inline workspace URL + secret in the dashboard or via the API.
Dashboard: Workspace Settings → Webhooks → Default routing & status callbacks. Set the Status callback URL and a Status callback secret (16+ characters) — or click Generate to have Beep create one and show it once — then save. The secret is stored encrypted and never shown again, only a "set" indicator.
API (requires a manager- or admin-role API key):
curl -X PUT https://api.beepmessaging.com/api/v1/notification-settings \
-H "Authorization: Bearer beep_live_..." \
-H "Content-Type: application/json" \
-d '{
"status_callback_url": "https://app.example.com/hooks/beep-status",
"status_callback_secret": "a-strong-shared-secret-value"
}'To rotate the secret, send a new status_callback_secret. The secret is never returned by GET /api/v1/notification-settings — the response only includes status_callback_secret_set: true.
Prefer to let Beep generate the secret? Send generate_status_callback_secret: true instead of status_callback_secret, and Beep returns the new value once as generated_secret (store it then — it is never returned again):
curl -X PUT https://api.beepmessaging.com/api/v1/notification-settings \
-H "Authorization: Bearer beep_live_..." \
-H "Content-Type: application/json" \
-d '{ "generate_status_callback_secret": true }'
# -> { "data": { "status_callback_secret_set": true, "generated_secret": "…" } }Events
Each callback represents one lifecycle transition:
event | status | Meaning |
|---|---|---|
message.queued | queued | Accepted and queued for sending |
message.sent | sent | Handed off to the carrier |
message.delivered | delivered | Carrier confirmed delivery |
message.failed | failed | Terminal failure (see error_code) |
message.opt_out | opt_out | Recipient opted out (inbound STOP) |
Pre-send failures also arrive as callbacks. If a message is rejected before any carrier call — the recipient is opted out, the number is a known landline, provider config is missing — Beep emits message.failed with an error_code. You never need to poll to discover these. See opt-outs & consent for the suppression model.
Not every message emits every event — handle the events you receive idempotently rather than assuming a fixed sequence. In particular, a live message that hands off to the carrier immediately may go straight to message.sent without a separate message.queued. (In test mode the simulator drives the full lifecycle, so you'll see queued there.)
Payload
{
"event": "message.delivered",
"event_id": "8f3c1e2a-...",
"message_id": "b1a2c3d4-...",
"client_reference": "your-id-123",
"to": "+15551234567",
"status": "delivered",
"error_code": null,
"metadata": { "order_id": "ord_42" },
"occurred_at": "2026-06-22T15:04:05.000Z"
}eventis namespaced (message.<transition>);statuscarries the bare transition.client_referenceechoes the value you supplied at send time, ornull.error_codeis populated onfailedevents, otherwisenull.metadataechoes the JSON you attached to the message at send time (themetadatafield onPOST /api/v1/messages), ornull.event_idis unique per transition — use it for idempotent processing.
Verify the signature
Every callback carries:
X-Beep-Signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed with the resolved destination's secret>
X-Beep-Timestamp: <unix seconds>
To verify:
- Read the raw request body bytes — do not re-serialize parsed JSON.
- Compute
HMAC-SHA256(secret, rawBody)and hex-encode it. - Constant-time compare against the hex after
sha256=. - Optionally reject requests whose
X-Beep-Timestampis too old.
import crypto from 'node:crypto';
// `rawBody` is the exact bytes Beep sent (e.g. express.raw()).
function verifyBeepSignature(rawBody, headers, secret) {
const header = headers['x-beep-signature'] || '';
const provided = header.startsWith('sha256=') ? header.slice(7) : header;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Each callback is signed with the secret of whichever target resolved: a per-number or workspace-default destination signs with its own secret, while a per-send override and the legacy inline URL sign with the workspace secret. Verify with the secret of the destination that should receive that number's callbacks. Callbacks are unsigned only when no secret has been configured for the resolved target.
Retries & idempotency
A delivery that gets a non-2xx response or a transport error is retried on a fixed, capped schedule, then abandoned:
1m → 5m → 30m → 2h → 24h (5 retries, then exhausted)
event_id is stable across retries. Make your endpoint idempotent on event_id so a retried event isn't processed twice.
Related
- Testing status callbacks — drive every event + verify your receiver in test mode
- Inbound replies — the other direction: where customer replies go
- Environments — test vs live keys
- Opt-outs & consent — how suppression surfaces in callbacks
Updated 3 months ago