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.
Register a webhook
Section titled “Register a webhook”You register webhooks in the console. An admin of your account must do this. Webhooks cannot be managed with an API key today.
- In the console, open Settings → Developers → Webhooks (the Outbound webhooks card).
- Click New webhook.
- 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 withhttps://. 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.
- Name: for example
- Click Register.
- 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.
Events
Section titled “Events”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.
What a delivery looks like
Section titled “What a delivery looks like”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.
inbound.message
Section titled “inbound.message”{ "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.
message.sent
Section titled “message.sent”{ "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. |
Verify the signature
Section titled “Verify the signature”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:
- Read the raw body bytes, before any JSON parsing. Parsing and printing the JSON again changes the bytes and breaks the signature.
- Compute HMAC-SHA256 of the raw body with your secret (the whole secret, including
whsec_). - Compare your result with the header value after
sha256=, using a constant-time comparison. - If they are not equal, answer
401and 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);});# Flask example. request.get_data() returns the raw body bytes.import hashlibimport hmacimport jsonimport os
from flask import Flask, request
app = Flask(__name__)SECRET = os.environ["SANADWA_WEBHOOK_SECRET"].encode() # whsec_...
def is_valid_signature(raw_body: bytes, header: str | None) -> bool: expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest() received = (header or "").removeprefix("sha256=") return hmac.compare_digest(expected, received)
@app.post("/webhooks/sanadwa")def sanadwa_webhook(): raw = request.get_data() if not is_valid_signature(raw, request.headers.get("X-Sanad-Signature")): return "", 401 delivery_id = request.headers.get("X-Sanad-Delivery-Id") event = json.loads(raw)
# Answer quickly, then do the work (for example, put it on a queue). enqueue(delivery_id, event) return "", 200Answer quickly, and retries
Section titled “Answer quickly, and retries”- Answer with any
2xxstatus 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-Idto ignore deliveries you have already processed. - Use the timestamps in
data(delivered_at,read_at, …) instead of the arrival order.
Test your webhook
Section titled “Test your webhook”- In Settings → Developers → Webhooks, click Send test event next to your webhook.
- SanadWA queues one test delivery to that webhook. It arrives within a few seconds.
- 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.