Skip to content

Webhooks

Instead of polling, have us deliver to you. Add an endpoint under Dashboard → Webhooks: give it a name, a URL, and the events it should receive, then keep the signing secret it hands back.

Events are queued the moment they happen and sent straight away — typically within a tenth of a second, not on a timer.

Event Fires when
message.status a message you sent is sent, delivered, read or fails
message.received somebody writes to you
call.status anything happens to a call
call.ringing a call is ringing — forwarded numbers only, carries the SDP offer
call.accepted a call you placed was answered — carries Meta’s SDP answer

Every request body is:

{
"event": "message.status",
"sentAt": "2026-09-27T10:00:04.221Z",
"data": { … }
}

The same body is sent whichever transport the endpoint uses, so a receiver reads one shape either way.

An endpoint’s URL decides how we reach it.

URL How it is delivered
https://… one POST per event, with the signature in a header
wss://… we dial out to your socket, hold it open, and push each event as a frame

A socket is for a receiver that cannot accept inbound HTTP. We open one connection per endpoint, reuse it for every event, and close it when nothing has used it for a few minutes — the next event dials again. Nothing is sent from your side; the socket is ours to open and yours to accept.

Both schemes must be encrypted and publicly resolvable. Plain http:// or ws:// is refused, as is localhost and any private address.

By default an endpoint subscribed to message.received hears about every inbound message in the workspace.

A single number can be pointed at one endpoint instead: on the number’s Replies panel choose Send it to a webhook and pick one. That number’s inbound messages then go to that endpoint and only there — they are not also delivered to everything else subscribed to message.received, so nothing arrives twice.

The same setting is what sends that number’s calls to the endpoint, with the SDP offer on them — see below.

Assignment routes; it does not subscribe. The endpoint still has to list message.received, or nothing reaches it — the console refuses the assignment and says so rather than letting you configure silence.

// message.status
{ "messageId": "cmu…", "conversationId": "cmu…", "providerMessageId": "wamid.…",
"status": "DELIVERED", "occurredAt": "2026-09-27T10:00:04.000Z",
"error": { "title": "…" } } // error only on FAILED
// message.received
{ "messageId": "cmu…", "conversationId": "cmu…", "text": "where is my order?",
"from": "919876543210", "fromName": "Ravi", "to": "+919812345678",
"optChange": "opted-out" } // optChange only when the text was STOP or START
// message.received — the answer to a call permission request
{ "messageId": "cmu…", "conversationId": "cmu…", "text": "Allowed calls",
"from": "919876543210", "fromName": "Ravi", "to": "+919812345678",
"details": { "callPermission": {
"response": "accept", "granted": true, "isPermanent": false,
"expiresAt": "2026-10-16T10:00:00.000Z", "source": "user_action" } } }
// call.status
{ "callId": "cmu…", "conversationId": "cmu…", "wacid": "wacid.…",
"direction": "INBOUND", "status": "MISSED", "contactWaId": "919876543210",
"occurredAt": "2026-09-27T10:00:04.000Z", "durationSeconds": null }

A number set to Forward also has each of its calls delivered to that endpoint directly, carrying the SDP offer — which is what lets your own server answer the call. See Answering calls yourself.

// call.ringing — only to the endpoint a number forwards to
{ "event": "call.ringing", "channel": "WHATSAPP",
"call": { "id": "cmu…", "wacid": "wacid.…", "direction": "INBOUND",
"status": "RINGING", "from": "919876543210", "fromName": "Ravi",
"to": "+919812345678", "phoneNumberId": "123456789012345",
"conversationId": "cmu…", "durationSeconds": null,
"session": { "sdpType": "offer", "sdp": "v=0\r\no=- …" } } }

A call you placed is delivered the same way when the person picks up. The SDP on it is Meta’s answer to the offer you sent, which is the half of the negotiation POST /conversations/:id/call deliberately does not return:

// call.accepted — only to the endpoint a number forwards to
{ "event": "call.accepted", "channel": "WHATSAPP",
"call": { "id": "cmu…", "wacid": "wacid.…", "direction": "OUTBOUND",
"status": "ACCEPTED", "from": "919876543210", "fromName": "Ravi",
"to": "+919812345678", "phoneNumberId": "123456789012345",
"conversationId": "cmu…", "durationSeconds": null,
"session": { "sdpType": "answer", "sdp": "v=0\r\no=- …" } } }

from is the customer and to is your number on both events — they describe the thread, not who dialled. Read direction to tell which way the call went.

This one does not follow the delivery rules below, and the difference matters:

  • 5-second timeout, and no retries at all. Meta holds a ringing call open for 30–60 seconds, so a delivery retried ten minutes later is worthless. Answer with 200 immediately and do the work afterwards.
  • No delivery-log row and no replay. Failures are recorded on the call itself as lastForwardError, which is what the console shows beside that call.
  • The session is only ever on a live call. Once the call is answered or over the offer is gone; GET /calls/:id/offer is the way to ask for a fresh one.

call.status still arrives by the normal queued route, with retries, for every transition — so you get both: this one to answer with, and that one for the record.

x-communication-signature-256 carries sha256= followed by an HMAC-SHA256 of the raw request body under your endpoint’s secret.

Verify over the exact bytes received: JSON.parse followed by JSON.stringify does not reproduce them.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
const expected = `sha256=${createHmac("sha256", process.env.VAARTO_WEBHOOK_SECRET)
.update(req.body)
.digest("hex")}`;
const given = req.get("x-communication-signature-256") ?? "";
if (
given.length !== expected.length ||
!timingSafeEqual(Buffer.from(given), Buffer.from(expected))
) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
// Answer first, work afterwards. Anything that is not 2xx is retried, and a
// slow handler is indistinguishable from a broken one.
res.sendStatus(200);
switch (event) {
case "message.status":
void onStatus(data.messageId, data.status, data.error);
break;
case "message.received":
void onInbound(data.from, data.text);
break;
case "call.status":
void onCall(data.callId, data.status, data.durationSeconds);
break;
}
});
app.listen(8080);

A socket cannot carry a header per message, so the handshake is what proves who is calling. The upgrade request carries three headers:

Header What it is
x-communication-timestamp when we dialled, in milliseconds
x-communication-nonce a UUID, new for every connection
x-communication-signature-256 sha256= and an HMAC-SHA256 of <timestamp>.<nonce> under your secret

The HMAC is the same construction in any language — only the server differs, so this one is shown in Node alone.

import { createHmac, timingSafeEqual } from "node:crypto";
import { WebSocketServer } from "ws";
const server = new WebSocketServer({ port: 8081 });
server.on("connection", (socket, request) => {
const timestamp = request.headers["x-communication-timestamp"] ?? "";
const nonce = request.headers["x-communication-nonce"] ?? "";
const given = request.headers["x-communication-signature-256"] ?? "";
const expected = `sha256=${createHmac("sha256", process.env.VAARTO_WEBHOOK_SECRET)
.update(`${timestamp}.${nonce}`)
.digest("hex")}`;
// Reject a stale handshake as well as a wrong one, or a captured upgrade
// request could be replayed forever.
const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60_000;
if (
!fresh ||
given.length !== expected.length ||
!timingSafeEqual(Buffer.from(given), Buffer.from(expected))
) {
socket.close(1008, "bad signature");
return;
}
socket.on("message", (raw) => {
const { event, data } = JSON.parse(raw.toString("utf8"));
// Every frame on this socket is ours; the handshake already proved it.
void handle(event, data);
});
});

Rotating the secret, or editing the endpoint’s URL, drops the connection — the next event dials again so you are asked to verify the new secret.

  • Encrypted, public host, 10-second timeout. Anything else is rejected when you add the endpoint.
  • Retried six times — after 10s, 1m, 5m, 30m and 2h, then marked failed. A 404 counts as a retry: endpoints come back mid-deploy. A socket that is not open when an event is due fails the same way, and the retry dials again. The one exception is the call.ringing forward above, which has 5 seconds and no second chance.
  • At least once. A delivery you accepted slowly may arrive again. Key your handler on data.messageId or data.callId and make a repeat a no-op.
  • Delivered means written, on a socket. There is no status code to read, so a frame accepted by an open socket counts as delivered. If you need to be certain you processed it, write it down before you act on it.
  • Paused when dead. Twenty consecutive failures switches an endpoint off so the queue stops growing. Turn it back on from the console once your end is fixed.

Every attempt, with what your server answered, is kept in the console’s delivery log, and a failed one can be replayed — the same bytes, so the signature still matches.