Concepts

Webhooks and events

A webhook sends an HTTP POST to your server when something happens, such as an incoming message. It is the best way to receive messages and delivery updates, so you do not have to poll. Webhooks need a running plan.

Tusk WhatsApp API: Webhooks and events

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.

201 Created
{
  "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?

EventFires when
message.receivedA message arrives.
message.sentA message is sent from the linked number.
message.ackA message delivery or read status changes.
message.failedA message could not be sent.
message.revokedA message is deleted for everyone.
message.reactionSomeone reacts to a message.
message.editedA message is edited.
status.receivedA status update is received.
session.statusThe session status changes.
session.qrA new QR code is available.
session.authenticatedThe number was linked.
session.disconnectedThe session disconnected.
session.reconnect_loopThe session keeps failing to reconnect.
session.restrictionWhatsApp restricted the number.
presence.updateA contact's online or typing presence changes.
group.joinSomeone joins a group.
group.leaveSomeone leaves a group.
group.updateGroup details change.
group.join_requestSomeone asks to join a group.
call.receivedAn incoming call arrives.
call.acceptedA call is accepted.
call.rejectedA call is rejected.
call.missedA call is missed.
webhook.testYou 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.
Request body
{
  "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 408 and 429.
  • 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}/test sends a signed webhook.test and returns success, status_code, error and duration_ms. GET /webhooks/{id}/deliveries shows 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.