Sending
Webhooks
We POST to your server when something happens to a message you sent: it left, it arrived, it was read, it failed, or somebody replied. Optional, but the alternative is polling.
Setting one up
In the console, Developers, then Webhook. Give us a name, an https URL and the events that endpoint should receive. You get a signing secret, shown once.
Your endpoint should answer any 2xx within ten seconds. Anything else counts as a failure and we retry.
What you receive
POST /your/webhook
Content-Type: application/json
Sendrix-Signature: t=1787929823,v1=56868badbdea6b...
Sendrix-Event: message.delivered
Sendrix-Delivery: 0d8e6ac6-1d64-4d4f-9f4b-9c2a0e5b3f11
{
"id": "0d8e6ac6-1d64-4d4f-9f4b-9c2a0e5b3f11",
"type": "message.delivered",
"created_at": "2026-08-28T15:10:22.985Z",
"data": {
"message_id": "msg_c16cead6-8385-481f-9e2e-e146d51f2738",
"to": "919833663235",
"status": "delivered",
"wa_message_id": "wamid.HBgMOTE5ODMzNjYzMjM1FQIAERgSN...",
"occurred_at": "2026-08-28T15:10:21.000Z",
"error": null
}
}Events
| Event | Fires when |
|---|---|
message.sent | Meta accepted the message. For a one-time code this is the first useful signal and arrives well before delivery. |
message.delivered | It reached the handset. |
message.read | The recipient opened it. Only if they have read receipts on. |
message.failed | It will not arrive. data.error carries Meta’s code. |
message.received | Somebody replied to you. |
Verify the signature
Anyone who learns your URL can post to it. The signature is what proves a request came from us, so check it before you trust the body.
Sendrix-Signature carries a timestamp and a hash: t=1787929823,v1=56868b.... The hash is HMAC-SHA256 over the literal string {t}.{raw body}, keyed with your signing secret.
import crypto from "node:crypto";
// The RAW body, before any JSON parsing. Re-serialising changes the bytes
// and the signature will never match.
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
// Reject anything older than five minutes. The timestamp is inside the
// signature, so this is what stops a captured request being replayed.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// Constant time, so a wrong signature cannot be found one byte at a time.
const a = Buffer.from(parts.v1 ?? "", "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Retries
A failed delivery is retried seven times over about nine hours: immediately, then after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Short at first because most failures are a deploy that lasted seconds.
Every attempt is recorded with what your server answered and how long it took, visible under Developers, then Webhook. You can retry any delivery by hand from there, which is what you do the moment after fixing your endpoint.
We switch it off eventually
After twenty consecutive failures the endpoint is disabled and the reason recorded. Something failing that consistently is gone rather than busy. Fix it, then turn it back on in the console and we resume.
Reading your endpoints back
GET /v1/webhook lists them, with the webhooks:read scope. Configuring them stays in the console: pointing a webhook somewhere new is a trust decision a person should make while signed in, not something an integration can do to itself with a key it already holds.
{
"object": "list",
"data": [
{
"id": "whk_9f2a4c1b-...",
"object": "webhook",
"name": "Order service",
"url": "https://acme.io/hooks/sendrix",
"secret_hint": "whsec_9f2a...4c1b",
"events": ["message.delivered", "message.failed"],
"enabled": true,
"disabled_reason": null,
"last_success_at": "2026-08-28T15:10:22.985Z"
}
],
"total": 1
}Writing a good receiver
- Answer fast, work later. Acknowledge with a
200and do your processing on a queue. Ten seconds is the limit and a slow receiver becomes a failing one. - Deduplicate on
id. Delivery is at-least-once on purpose: we would rather send a receipt twice than lose one. The same event id may arrive more than once. - Do not assume order. Retries mean
deliveredcan arrive afterread. Usedata.occurred_atif sequence matters. - Ignore what you do not know. New event types and new fields will appear. See Versioning.