Skip to content

Webhooks

A webhook is an HTTPS address on your server that SanadWA calls when something happens, for example when a customer sends you a WhatsApp message or when a message you sent is delivered. You do not have to ask the API for changes; SanadWA tells you.

You register webhooks in the console. An admin of your account must do this. Webhooks cannot be managed with an API key today.

  1. In the console, open Settings → Developers → Webhooks (the Outbound webhooks card).
  2. Click New webhook.
  3. Fill in the form:
    • Name: for example Order system.
    • HTTPS URL: the full address on your server, for example https://example.com/webhooks/sanadwa. It must start with https://. It must be reachable from the internet: addresses that point to private or internal networks are refused.
    • Subscribed events: choose Everything to receive every event type, including types we add later, or Only the events I choose and pick events from the list below.
  4. Click Register.
  5. Copy the signing secret that the console shows. It starts with whsec_. It is shown only once. You need it to verify signatures. If you lose it, delete the webhook and register a new one.

You can change the subscribed events later (Edit events) without changing the secret. You can Disable a webhook; while it is disabled, SanadWA does not queue new events for it.

These are all the events SanadWA sends today.

Event When it is sent
inbound.message A customer sent a WhatsApp message to one of your numbers.
message.sent WhatsApp accepted a message that you sent with POST /messages/template or POST /messages/text. It is not sent for campaign messages (also campaigns created with the API) or for messages sent from the console.
message.delivered WhatsApp reports that a message reached the recipient’s phone.
message.read WhatsApp reports that the recipient opened the message.
message.failed WhatsApp reports that a message could not be delivered.

message.delivered, message.read and message.failed are sent for messages from your account that were sent with this API, and also for messages sent from the console, for example campaign messages and inbox replies. Each is sent once per message, when the status changes.

In the console’s event list you may also see message.received. It is an old name for inbound.message. If you choose it, SanadWA saves inbound.message instead. Every delivery uses inbound.message.

SanadWA sends an HTTP POST request to your URL with a JSON body:

{
"event": "message.delivered",
"tenant_id": 1234,
"data": { }
}
Field Meaning
event The event type, from the table above.
tenant_id Your SanadWA account id. It is the same value that GET /health returns.
data The event’s details. The fields depend on the event; see below.

And these headers:

Header Meaning
Content-Type application/json
X-Sanad-Event The event type, the same as event in the body.
X-Sanad-Signature sha256= followed by the signature. See Verify the signature.
X-Sanad-Delivery-Id The id of this delivery. A retry of the same delivery has the same id. Use it to ignore duplicates.
User-Agent Sanad-Webhook/1.0

As explained in Versioning, new fields can be added to data. Ignore fields you do not know.

{
"event": "inbound.message",
"tenant_id": 1234,
"data": {
"message_id": "wamid.HBgLMTIwMjU1NTAxNDUVAgASGBQ",
"from": "+12025550145",
"type": "text",
"timestamp": "2026-11-11T08:10:00.000Z",
"text": "Hello, is my order ready?"
}
}
Field Meaning
message_id The WhatsApp message id of the customer’s message.
from The customer’s phone number in E.164 format.
type The message type, for example text, image, document, button or interactive. Unknown types are unknown.
timestamp When the customer sent the message.
text The text, or a media caption, or the text of the button the customer tapped. Missing when there is no text.
media Only for media messages: id, mime_type, sha256, filename and caption. The id is the WhatsApp media id.
button Only for button and interactive messages: text, and payload (the id of the button or list item).

When the customer writes, the 24-hour customer-service window opens. During that window you can reply with free-form text using POST /messages/text.

{
"event": "message.sent",
"tenant_id": 1234,
"data": {
"message_id": "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI",
"to_phone": "+12025550123",
"template_name": "order_update",
"template_language": "en",
"sent_at": "2026-11-11T08:00:01.000Z"
}
}

message_id is the same value that the send endpoint returned. For a text message sent with POST /messages/text, data also has "message_type": "text" and text, and template_name and template_language are null.

message.delivered, message.read and message.failed

Section titled “message.delivered, message.read and message.failed”
{
"event": "message.failed",
"tenant_id": 1234,
"data": {
"message_id": 987654,
"provider_msg_id": "wamid.HBgLMTIwMjU1NTAxMjMVAgARGBI",
"to_phone": "+12025550123",
"failed_at": "2026-11-11T08:00:05.000Z",
"error": {
"code": 131026,
"title": "Message undeliverable",
"message": "Message undeliverable",
"error_data": { "details": "Message Undeliverable." },
"details": "Message Undeliverable.",
"error_user_msg": null
}
}
}
Field Meaning
provider_msg_id The WhatsApp message id. Match on this field: it is the message_id that the send endpoint returned. You can also look the message up with GET /messages/{id}.
message_id SanadWA’s internal number for the message. It is not the WhatsApp message id.
to_phone The recipient’s phone number.
delivered_at, read_at or failed_at When the status changed. Each event has the one field that matches it.
error Only in message.failed: the error that WhatsApp reported. code is a WhatsApp error code.

Anyone can send a request to your webhook URL. To know that a request really comes from SanadWA, check its signature.

The signature is an HMAC-SHA256 of the raw request body, using your signing secret as the key, written as lowercase hex. The X-Sanad-Signature header has the form sha256=<signature>.

To check it:

  1. Read the raw body bytes, before any JSON parsing. Parsing and printing the JSON again changes the bytes and breaks the signature.
  2. Compute HMAC-SHA256 of the raw body with your secret (the whole secret, including whsec_).
  3. Compare your result with the header value after sha256=, using a constant-time comparison.
  4. If they are not equal, answer 401 and ignore the request.
// Express example. express.raw keeps the body as bytes for the signature check.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SANADWA_WEBHOOK_SECRET; // whsec_...
function isValidSignature(rawBody, header) {
const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const received = String(header || "").replace(/^sha256=/, "");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/sanadwa", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-Sanad-Signature"))) {
return res.sendStatus(401);
}
const deliveryId = req.get("X-Sanad-Delivery-Id");
const event = JSON.parse(req.body.toString("utf8"));
// Answer quickly, then do the work (for example, put it on a queue).
res.sendStatus(200);
handleEvent(deliveryId, event);
});
  • Answer with any 2xx status to confirm that you received the event.
  • Answer within 5 seconds. If you need more time, store the event (for example in a queue) and answer first.
  • Redirects are followed. Any other answer, a timeout or a connection error counts as a failure.

If a delivery fails, SanadWA tries again, about 5 seconds, 30 seconds, 2 minutes and 10 minutes after each failed attempt. That is 5 attempts in total, over about 13 minutes. After the fifth failed attempt, SanadWA stops trying that delivery. The console shows its status as Gave up.

If your URL now points to a private or internal network address, SanadWA does not deliver to it and does not retry.

Because of retries, your server can receive the same delivery more than once (for example, if your answer took longer than 5 seconds). Events can also arrive in a different order than they happened, for example message.read before message.delivered. To handle this:

  • Use X-Sanad-Delivery-Id to ignore deliveries you have already processed.
  • Use the timestamps in data (delivered_at, read_at, …) instead of the arrival order.
  1. In Settings → Developers → Webhooks, click Send test event next to your webhook.
  2. SanadWA queues one test delivery to that webhook. It arrives within a few seconds.
  3. Click Deliveries to see each attempt, with your server’s HTTP status and the start of its response body.

The test event ignores the webhook’s event filter. Its event is inbound.message, and its data has "test": true:

{
"event": "inbound.message",
"tenant_id": 1234,
"data": {
"test": true,
"from": "<a fixed sample number>",
"message": { "type": "text", "text": { "body": "SanadWA test event." } }
}
}

The test data does not have the same fields as a real inbound.message. Use the test to check your URL and your signature check, and make your code ignore events whose data.test is true.

To test with real events, send a template message to your own phone with the API, then reply to it. You receive message.sent, message.delivered, message.read and inbound.message.