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.
HTTPS or WebSocket
Section titled “HTTPS or WebSocket”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.
Per-number routing
Section titled “Per-number routing”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.
Payloads
Section titled “Payloads”// 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 }Ringing calls are delivered differently
Section titled “Ringing calls are delivered differently”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
200immediately 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
sessionis only ever on a live call. Once the call is answered or over the offer is gone;GET /calls/:id/offeris 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.
Verifying a delivery — HTTPS
Section titled “Verifying a delivery — HTTPS”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);import hashlibimport hmacimport os
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ["VAARTO_WEBHOOK_SECRET"].encode()
@app.post("/hooks/whatsapp")def whatsapp(): # The raw bytes, not the parsed body: json.loads followed by json.dumps # does not reproduce them. expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest() given = request.headers.get("x-communication-signature-256", "")
if not hmac.compare_digest(given, expected): abort(401)
payload = request.get_json()
# Answer first, work afterwards. Anything that is not 2xx is retried, and a # slow handler is indistinguishable from a broken one — so the work goes on # a queue rather than in the request. enqueue(payload["event"], payload["data"])
return "", 200<?php// Exempt this route from CSRF verification: the signature is the credential.Route::post('/hooks/whatsapp', function (Request $request) { // getContent() is the raw body. Decoding and re-encoding would not // reproduce the bytes the signature was taken over. $expected = 'sha256=' . hash_hmac( 'sha256', $request->getContent(), env('VAARTO_WEBHOOK_SECRET'), );
$given = $request->header('x-communication-signature-256', '');
abort_unless(hash_equals($expected, $given), 401);
$payload = $request->json()->all();
// Answer first, work afterwards. Anything that is not 2xx is retried, and // a slow handler is indistinguishable from a broken one. HandleWhatsAppEvent::dispatch($payload['event'], $payload['data']);
return response()->noContent();})->withoutMiddleware(VerifyCsrfToken::class);Verifying a connection — WebSocket
Section titled “Verifying a connection — WebSocket”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.
Delivery rules
Section titled “Delivery rules”- 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
404counts 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 thecall.ringingforward 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.messageIdordata.callIdand 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.