How do I create a webhook?
Register an HTTPS URL and the events you want. Use ["*"] for every event. A webhook covers all of your sessions unless you set session_id to one session id.
curl -X POST "https://api.tuskmail.africa/v1/whatsapp/webhooks" \
-H "X-API-Key: $TUSK_WA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/whatsapp",
"events": [
"message.received",
"message.ack",
"message.failed"
],
"description": "Order bot",
"session_id": "9c1f6a4e-2b7d-4e0a-8f53-6d2a1b7c9e10"
}'You can choose your own secret (16 to 100 characters). If you leave it out, one is generated. The response includes the full secret once, and again only when you rotate it. Store it right away.
{
"id": "5b0e9c3a-7d1f-4a62-9c1e-2f8a6d4b7e31",
"url": "https://example.com/hooks/whatsapp",
"events": ["message.received", "message.ack", "message.failed"],
"active": true,
"session_id": "9c1f6a4e-2b7d-4e0a-8f53-6d2a1b7c9e10",
"secret": "whsec_generated_once_16plus_chars",
"consecutive_failures": 0
}Which events can I subscribe to?
| Event | Fires when |
|---|---|
message.received | A message arrives. |
message.sent | A message is sent from the linked number. |
message.ack | A message delivery or read status changes. |
message.failed | A message could not be sent. |
message.revoked | A message is deleted for everyone. |
message.reaction | Someone reacts to a message. |
message.edited | A message is edited. |
status.received | A status update is received. |
session.status | The session status changes. |
session.qr | A new QR code is available. |
session.authenticated | The number was linked. |
session.disconnected | The session disconnected. |
session.reconnect_loop | The session keeps failing to reconnect. |
session.restriction | WhatsApp restricted the number. |
presence.update | A contact's online or typing presence changes. |
group.join | Someone joins a group. |
group.leave | Someone leaves a group. |
group.update | Group details change. |
group.join_request | Someone asks to join a group. |
call.received | An incoming call arrives. |
call.accepted | A call is accepted. |
call.rejected | A call is rejected. |
call.missed | A call is missed. |
webhook.test | You send a test ping. Always delivered to the tested webhook. |
* | Every event above. |
What does a delivery look like?
Each delivery is a POST with a JSON body and these headers.
- X-Tusk-Event
- The event name, such as message.received.
- X-Tusk-Delivery
- The event id. Same as id in the body. Use it to ignore duplicates.
- X-Tusk-Timestamp
- Unix time in seconds when the delivery was signed.
- X-Tusk-Signature
sha256=<hex>, see below.
{
"id": "evt_8f2c1a9d03b44e7a",
"event": "message.received",
"session_id": "9c1f6a4e-2b7d-4e0a-8f53-6d2a1b7c9e10",
"created_at": "2026-01-15T09:30:00Z",
"data": { }
}data carries the event details from WhatsApp, and its fields vary by event. Use the webhook.test ping to see a real delivery. Respond with a 2xx within 8 seconds, and do heavy work afterwards.
How do I verify the signature?
The signature is HMAC-SHA256(secret, timestamp + "." + rawBody), hex encoded and prefixed with sha256=. Use the raw request bytes, before any JSON parsing, and compare in constant time. Also reject timestamps that are more than a few minutes old to stop replays.
import crypto from "node:crypto";
import express from "express";
const app = express();
// Keep the raw body: the signature covers the exact bytes.
app.post("/hooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Tusk-Timestamp") ?? "";
const signature = req.get("X-Tusk-Signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.TUSK_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body) // Buffer
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event, req.get("X-Tusk-Delivery"));
res.sendStatus(200);
});What about retries and failures?
- A failed delivery is attempted up to 3 times. Responses in the 4xx range are not retried, except
408and429. - After 25 consecutive failures the webhook is switched off. Fix your endpoint, then call
POST /webhooks/{id}/enable, or enable it in the web app. - Deliveries can repeat or arrive out of order, so make your handler idempotent using
X-Tusk-Delivery. POST /webhooks/{id}/testsends a signedwebhook.testand returnssuccess,status_code,errorandduration_ms.GET /webhooks/{id}/deliveriesshows the last 100 attempts.
How many webhooks can I have?
The number depends on your plan (3 by default, and plans can include more). Creating one over the limit returns 403 webhook_limit. Without a running plan the limit is 0, and creating or receiving webhooks returns 402 subscription_required or subscription_expired. Check the plan picker in the web app for the current numbers. See the webhooks reference for every endpoint.