Skip to content

Every change on your numbers, signed and sent to you.

Ten events cover messages, statuses, reactions, chats, groups and the numbers themselves. Each one arrives as a signed JSON POST, retried for 24 hours until your endpoint answers.

POST https://example.com/hooks/relay
Content-Type: application/json
User-Agent: relay-webhooks/1
X-Relay-Event: message.status
X-Relay-Delivery: dlv_01a1cf088c4e7d2a9b5e1c6f3a8d0e72
X-Relay-Timestamp: 1794474850
X-Relay-Signature: v1=…
{
"id": "evt_01a1cf088c4e7d2a9b5e1c6f3a8d0e72",
"event": "message.status",
"created_at": "2026-11-12T09:14:10.502Z",
"data": { "id": "msg_…", "status": "read", … }
}
Signature
HMAC-SHA256
Answer within
10 seconds
Retries
24 hours, up to 10 attempts
Delivery
At least once

Ten events

Filter each endpoint by event and by number. An empty filter receives everything, including events and numbers added later.

Messages

Chats and groups

Numbers

message.received

A message arrived on one of your numbers, in a one-to-one chat or a group.

data is the full Message object, the same one GET /v1/messages/{id} returns.

X-Relay-Event:message.received
{
"id": "evt_01a1cf082f8a7c51b6e0d4a39f12c87e",
"event": "message.received",
"created_at": "2026-11-12T09:13:02.410Z",
"data": {
"id": "msg_01a1cf082d307e19a4c3b85f20d6e91a",
"number_id": "num_01a1a1c6c2007a3f9c41e2b7d5a60813",
"chat_id": "chat_01a1a1d0f4a87c128e5d4b9a2f61c37e",
"chat_type": "dm",
"chat_jid": "212612345678@s.whatsapp.net",
"wa_message_id": "3A9F0C21B7E4D85A6C12",
"from_me": false,
"sender": {
"jid": "212612345678@s.whatsapp.net",
"phone": "+212612345678",
"name": "Maison Exemple",
"contact_id": "ct_01a1a1d0f4b07e2c9a143c8f5b2e7d90"
},
"type": "text",
"body": "Perfect, thank you!",
"content": null,
"media": null,
"quoted_message_id": null,
"quoted_wa_message_id": null,
"mentioned_jids": [],
"is_forwarded": false,
"is_view_once": false,
"status": "delivered",
"error_code": null,
"source": "live",
"client_key": null,
"sent_at": "2026-11-12T09:13:02.000Z",
"edited_at": null,
"revoked_at": null
}
}
  • webhook.test is sent only by POST /v1/webhooks/endpoints/{id}/test or the dashboard's Send test event, never by a real change.
  • Events describe live activity. Messages imported from a number's history when it's linked, or recovered after an outage, are readable through the API but aren't sent as events.

Verify every request

Check the signature on the raw body before you parse it, and drop anything older than five minutes.

Headers

HeaderWhat it carriesExample
Content-TypeAlways JSON.application/json
User-AgentIdentifies our sender.relay-webhooks/1
X-Relay-EventThe event name, the same as event in the body.message.received
X-Relay-DeliveryThe delivery id, as shown in the delivery log.dlv_01a1cf082f8a…
X-Relay-TimestampUnix seconds when this attempt was signed.1794474782
X-Relay-Signaturev1= and the hex HMAC-SHA256.v1=5f2b…

The v1 scheme

signed_payload = X_Relay_Timestamp + "." + raw_body
signature = "v1=" + hex(HMAC_SHA256(secret, signed_payload))
  1. Read the raw body as bytes, before any JSON parsing.
  2. Join X-Relay-Timestamp, a dot and the raw body.
  3. HMAC-SHA256 it with the endpoint's secret (the whole whsec_… string), hex-encode it and prefix v1=.
  4. Compare the result with X-Relay-Signature in constant time.
  5. Reject a timestamp more than 300 seconds (5 minutes) away from your clock.

The secret (whsec_…) is shown when you add the endpoint and each time you rotate it. Rotating stops the old secret at once, so update your receiver right after.

POST/hooks/relay
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.RELAY_WEBHOOK_SECRET; // whsec_…
app.post("/hooks/relay", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Relay-Timestamp") ?? "";
const given = req.get("X-Relay-Signature") ?? "";
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const valid = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!fresh || !valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(204);
// Skip event.id if you have handled it, then act on event.event and event.data.
});
app.listen(3000);

Delivery

At least once, signed fresh on every attempt, retried with backoff for 24 hours.

Answer any 2xx within 10 seconds, then do the work. Timeouts, network errors, 408, 429 and 5xx are retried; any other status is final, and redirects aren't followed.

Every attempt posts the same body. Only X-Relay-Timestamp and X-Relay-Signature change.

One delivery, attempt by attemptmessage.statusevt_01a1cf07f1a27b6e9d20c3a85e17f4b9
succeeded
  1. Attempt 109:12:44
    http_503Your endpoint answered 503.
    X-Relay-Timestamp: 1794474764Next attempt in 30 s
  2. Attempt 209:13:14
    timeoutNo answer within 10 seconds.
    X-Relay-Timestamp: 1794474794Next attempt in 2 min
  3. Attempt 309:15:24
    200Your endpoint answered 200.
    X-Relay-Timestamp: 1794474924Delivered
The same body and the same evt_ id on every attempt.

Retry schedule

Each wait varies by up to 10% either way. When the next attempt would fall more than 24 hours after the event, the delivery ends failed.

AttemptWaitsAbout this long after the event
1At once0 s
230 s30 s
32 min2 min 30 s
410 min12 min 30 s
530 min42 min 30 s
61 h1 h 42 min
72 h3 h 42 min
84 h7 h 42 min
96 h13 h 42 min
108 h21 h 42 min

Duplicates

Deliveries are at least once. The body's id (evt_…) and the X-Relay-Delivery header (dlv_…) stay the same across retries and redeliveries: keep the ids you've handled and skip repeats. Two endpoints that receive the same change each get their own delivery, with its own id.

Auto-disable

After 10 deliveries in a row end failed, each after its full 24 hours of retries, the endpoint is disabled and you get a notification in the app. A shorter outage never trips it, and any success resets the count.

Fix the endpoint, then re-enable it from the dashboard or with enabled: true. Nothing is sent while it's off: redeliver the events you missed from the delivery log.

PATCH /v1/webhooks/endpoints/whk_01a1c5e0a9407e8db0c27f4a19d3e65b
Idempotency-Key: 7c2e4a90-1b3d-4f6e-8a5c-2d9b0e1f3a47
{ "enabled": true }

Redeliver and test

The delivery log, in the dashboard or at GET /v1/webhooks/deliveries, shows every attempt with your endpoint's answer. Redelivering a finished delivery sends the same body and the same id, with a fresh 24-hour window; it answers 409 webhook_delivery_pending while the delivery is still being attempted.

A test sends a signed webhook.test event to one endpoint, even a disabled one.

POST /v1/webhooks/deliveries/dlv_01a1cf07f1a27b6e9d20c3a85e17f4b9/redeliver
Idempotency-Key: 5b8d1f02-9c4e-4a7b-b6d3-0e2f8a1c7d94
HTTP/1.1 202 Accepted

Endpoint rules

An https:// URL on the public internet. Private, loopback, link-local and cluster-internal hosts are refused when you save (422 invalid_webhook_url) and checked again before every send (destination_not_public, not retried). We connect only to the address we checked, and redirects aren't followed.

Ordering

A webhook tells you something changed. The order they arrive in isn't guaranteed.

  • Events can arrive out of order, especially after retries. Compare created_at, or read the resource again, before you overwrite newer state.
  • A message status only moves forward: pending → sent → delivered → read, or failed. Ignore a status that would move it back.
  • message.reaction and group.participants carry the whole current list; chat.updated, group.updated and number.status carry the state after the change. Keep the newest by created_at.

Send yourself a test event.

Add your endpoint in Settings › Developers › Webhooks, send a test event and watch it land in the delivery log.