Sending
Webhooks
We POST to your server when something happens: a message you sent left, arrived, was read or failed; somebody messaged you; or somebody submitted a form. Optional, but the alternative is polling.
Setting one up
In the console, Developers, then Webhook, then Add endpoint. Give it a name, an https URL and the events it should receive.
Your endpoint should answer any 2xx within ten seconds. Anything else counts as a failure and we retry.
Your signing secret
There is no field to type a secret into. We generate one for every endpoint: 32 random bytes, starting whsec_. A secret a person chooses is usually guessable or already in use somewhere else, and this one exists only to prove a request came from us.
It is shown once, in a panel at the top of the page, the moment you add the endpoint. Copy it into your server’s configuration then: no page in the console and no API response ever returns it again. Afterwards the endpoint lists its first and last four characters, whsec_ab12...cd34, so you can tell which one you deployed.
Lost it, or think it leaked? Press New secret on the endpoint. You get a fresh one, shown once in the same way, and the old one stops working immediately. Deliveries that fail verification in the minutes before you deploy the new one are retried automatically and signed with the new secret, so nothing is lost if you deploy it promptly.
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 messaged one of your numbers: text, a photo, a location, a button tap, anything. Fires for every inbound message, whichever way the conversation started. Payload below. |
flow.completed | Somebody submitted a WhatsApp Flow form. Fires for every submission, whichever way the form was sent. |
message.received
One event per message somebody sends to any of your numbers. It fires once per message even though Meta may tell us about a message more than once, and it fires for every conversation, not only replies to messages your code sent.
{
"id": "8f3b2a61-0c4d-4e5f-9a7b-1c2d3e4f5a6b",
"type": "message.received",
"created_at": "2026-09-21T10:15:04.112Z",
"data": {
"message_id": "msg_c16cead6-8385-481f-9e2e-e146d51f2738",
"wa_message_id": "wamid.HBgMOTE5ODMzNjYzMjM1FQIAEhgUM0E...",
"from": "919833663235",
"profile_name": "Asha",
"sender_id": "snd_c9288d80-3860-45c0-bcec-577b19aaab62",
"type": "location",
"text": null,
"received_at": "2026-09-21T10:15:03.000Z",
"in_reply_to": null,
"reply": null,
"media": null,
"location": {
"latitude": 18.5204,
"longitude": 73.8567,
"name": null,
"address": null,
"url": null
}
}
}Every field is always present. One that does not apply to this message is null rather than missing, so you can read data.location on any message without checking the type first.
| Field | What it is |
|---|---|
message_id | Our id for the message. Stable across redeliveries of the event. |
wa_message_id | WhatsApp's own id, the one in Meta's logs. |
from | The sender’s number in international form, no +: 919833663235. |
profile_name | The name they set on their own WhatsApp, or null when WhatsApp did not send one. Not a verified name. |
sender_id | Which of your numbers they messaged, as in Senders. |
type | What kind of message. See the table below. Treat a value you do not recognise as “something else”: new ones may appear. |
text | What they typed, a photo’s caption, or the label of a button they tapped. null for a location, a contact card, an order and a form. |
received_at | When they sent it, per WhatsApp. |
in_reply_to | Set when they used WhatsApp’s reply on an earlier message. wa_message_id is always set; message_id only when the quoted message is one of ours. |
reply | A tapped button or list row: { "id", "title" }. Branch on id, the value you set; title changes whenever the label is reworded. |
media | An image, video, audio note, document or sticker: { "id", "mime_type", "filename", "caption" }. See the note on files below. |
location | A shared location: { "latitude", "longitude", "name", "address", "url" }. Coordinates are numbers. |
The types
| type | Fields that are set |
|---|---|
text | text |
image | media, and text if it has a caption |
video | media, and text if it has a caption |
document | media including filename, and text if it has a caption |
audio | media. Voice notes arrive as audio. |
sticker | media |
location | location |
button | reply and text. A tap on a template’s quick-reply button; reply.id is the button’s payload. |
interactive | reply and text for a tapped reply button or list row. A submitted WhatsApp Flow is also interactive, with neither set: its answers come in flow.completed. |
contacts | A contact card. Only the ids and the sender are set. |
order | A cart sent from your catalogue. It also becomes an order in the console. |
unsupported | Anything else WhatsApp sends, such as a reaction. wa_message_id is set so you can still find it. |
Reading a location
A location shared as current location carries coordinates only. One picked from the map as a place also carries name and address. A live location is not delivered by WhatsApp to any business API, so it never produces an event: ask for a current location instead.
app.post("/hooks/sendrix", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString(), req.get("Sendrix-Signature"), SECRET)) {
return res.sendStatus(400);
}
res.sendStatus(200); // acknowledge first, work after
const event = JSON.parse(req.body.toString());
if (event.type !== "message.received") return;
const { from, text, location, reply } = event.data;
if (location) {
// Always numbers. name and address only for a place picked from the map.
savePickupPoint(from, location.latitude, location.longitude, location.address);
} else if (reply) {
handleChoice(from, reply.id); // the id you set, not the label
} else if (text) {
handleText(from, text);
}
});Testing it
Subscribe an endpoint to message.received, then send your own WhatsApp number a text and a location from your phone. Both deliveries appear under Developers, then Webhook, with the exact body we sent and what your server answered. That body is the thing to write your parser against.
flow.completed
{
"id": "5b1e0c3a-9d0f-4a39-8b5e-2f6c7a1d9e40",
"type": "flow.completed",
"created_at": "2026-09-15T12:50:46.225Z",
"data": {
"id": "frs_7c2d4e1a-0b3f-4c5d-9e6f-1a2b3c4d5e6f",
"message_id": "msg_2a9c1f7e-4d3b-4a8e-b1c2-3d4e5f6a7b8c",
"from": "919833663235",
"flow_token": "lead-8812",
"flow_id": "1019982851061063",
"source_message_id": "msg_c16cead6-8385-481f-9e2e-e146d51f2738",
"template_id": "tpl_cb9182c4-6e22-4095-bd26-a61c9f8d7fba",
"response": {
"screen_0_Full_name_0": "Asha Verma",
"screen_0_City_1": "Pune"
},
"files": [
{ "field": "photo_picker", "index": 0, "file_name": "IMG_5237.jpg", "mime_type": "image/jpeg" }
],
"received_at": "2026-09-15T12:50:44.000Z"
}
}flow_token is the token you chose with template.flow.token, or ours. source_message_id is the message that carried the form, when we could trace it. Keys in response are whatever the flow names its fields. Uploaded files are listed without their contents: fetch one with GET /v1/flow_responses/{id}/files/{index}.
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.