Everything that happens on a linked number — an inbound message, a read receipt, someone joining
a group, the phone being logged out — reaches you as a webhook: an HTTPS POST to a URL you
register, signed so you can prove it came from Wapito. This page is the contract for those
deliveries: the events, the envelope, the headers, the signature and how to verify it, and what
happens when your endpoint does not answer.
Registering a webhook
A webhook is a URL, a list of events and a signing secret. Create it from the channel's
Settings tab or with POST /webhooks:
curl -X POST https://api.wapito.com/v1/webhooks \
-H "Authorization: Bearer $WAPITO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/wapito",
"events": ["messages", "messages.status", "channel"],
"headers": {"X-Tenant": "eu-1"}
}'
{
"id": "whk_01JRQ8F4X9N2K7YB3C5V6W8H0T",
"url": "https://hooks.example.com/wapito",
"events": ["messages", "messages.status", "channel"],
"headers": {"X-Tenant": "eu-1"},
"method": "POST",
"enabled": true,
"secret": "whsec_4Kz9…",
"secret_preview": "whsec_…f31a"
}
The secret appears in that 201 response and nowhere else afterwards; store it with the
channel token. You can supply your own secret in the request instead of having one generated.
Event filters are exact names, prefix wildcards such as messages.*, or * for everything.
enabled: false keeps the subscription but skips it, which is how you pause an endpoint during a
deploy without losing its configuration. Sandbox channels may register two webhooks and Premium
channels five.
The URL must be https:// and must resolve to a public address. A hostname that points at a
private, loopback or link-local range is refused with 422 webhook_url_invalid, and the check is
repeated at every send, so a name cannot pass validation and later resolve somewhere internal.
Events
| Event | When it fires | Data |
|---|---|---|
messages | Every inbound and outbound message, including ones sent from the phone. | The message object, with from_me set. |
messages.edited | A message was edited. | message_id, chat_id, body, timestamp |
messages.deleted | A message was deleted for everyone. | message_id, chat_id, from, timestamp |
messages.status | A delivery receipt: failed, pending, sent, delivered, read or played. | message_id, chat_id, recipient, status, ack, timestamp |
messages.reactions | A reaction was added or removed. | message_id, chat_id, from, emoji, timestamp |
chats | A chat was archived or unarchived. | action, chat_id, archived |
contacts | A contact was seen for the first time, or its phone, LID or name changed. | action, id, phone, lid, name |
groups | The channel joined or left a group, or a subject, description or picture changed. | action, group_id, subject, description, picture |
groups.participants | Participants added, removed, promoted or demoted, or a join request. | group_id, action, participants[], by |
presences | Online, offline, typing and recording updates. | chat_id, presence, last_seen |
polls | A vote was cast, changed, or could not be decrypted. | poll_message_id, chat_id, voter, selected_options[], failed |
calls | An incoming call was received, accepted or rejected. | call_id, from, status, is_video, is_group |
labels | Business labels created, deleted, or attached to a chat (NOWEB engine). | action, label, chat_id |
channel | The channel connected, disconnected, was logged out or banned. | status, previous_status, phone, reason |
The channel event is the one to subscribe to even if you want nothing else: reason is
banned, logged_out, user_logout or engine_failed, and it is how you learn a number is
gone before a customer tells you. The anti-ban guide covers what to do
when it arrives.
Event names are stable. The engine's own event stream is never forwarded — the payloads below
are Wapito's schema, the same whichever engine the channel runs on — unless you switch on
include_raw in the channel settings, which adds a raw field to message objects for debugging.
The envelope
Every delivery has the same shape. data varies by event; everything around it does not:
{
"id": "evt_01JRQ8F4X9N2K7YB3C5V6W8H0T",
"event": "messages",
"channel_id": "ch_01JRQ8F4X9N2K7YB3C5V6W8H0T",
"timestamp": 1789459200123,
"api_version": "v1",
"data": {
"id": "false_15551234567@s.whatsapp.net_9F31A0C4D7E2B6081A55",
"chat_id": "15551234567@s.whatsapp.net",
"from": "15551234567",
"from_lid": null,
"from_name": "Dana",
"from_me": false,
"type": "text",
"timestamp": 1789459200000,
"source": "app",
"text": { "body": "Hi! Is the blue one still available?" },
"context": { "quoted_id": null, "quoted_author": null, "forwarded": false, "mentions": [] }
}
}
id is a ULID: unique per event and sortable by time, which makes it the right key for
de-duplication. timestamp is milliseconds since the epoch. Ids in data always use the
@s.whatsapp.net form; a group is …@g.us, and a sender whose number WhatsApp hides arrives with
from: null and a from_lid you can resolve with
GET /contacts/lid/{lid}.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Wapito-Webhooks/1.0 — allow-list it if your edge filters bots. |
X-Wapito-Event | The event name, so a router can dispatch without parsing the body. |
X-Wapito-Delivery | The delivery job id. Every retry of the same event carries the same value. |
X-Wapito-Channel | The channel id. |
X-Wapito-Signature | t=<ms>,v1=<hex> — see below. |
Your own headers from the subscription are added to every delivery. The six above are
reserved and cannot be overridden by them.
Verifying the signature
The signature header is:
X-Wapito-Signature: t=1789476800000,v1=35f8a64bb9bcf545b0eb1f28605779fb46c06faf1a1dd90082b964671c56723d
where t is the time the delivery was signed, in milliseconds, and v1 is the hex HMAC-SHA256
of the string "<t>.<raw body>" under your webhook secret. To verify:
- Parse
tandv1out of the header. - Reject the delivery if
|now − t|is more than 300 seconds. The timestamp is inside the signed string, so an old body cannot be replayed with a fresht. - Compute
HMAC-SHA256(secret, t + "." + rawBody)over the exact bytes you received — before any JSON parsing, re-serialising or whitespace changes. - Compare it to
v1with a constant-time comparison.
Wapito serialises the body once and signs those bytes, so a receiver that hashes the raw request always agrees with us. The one way this reliably goes wrong is a framework that parses JSON before your handler runs and hands you a re-encoded copy; every verifier below reads the raw body first.
A test vector you can run without a live channel — secret whsec_test, body {"ok":true},
t=1789476800000 — must produce exactly the header shown above.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WAPITO_WEBHOOK_SECRET;
/** Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>. */
function verify(raw, header) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() - Number(parts.t)) > 300_000) return false; // 5 minute clock skew
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(raw).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/wapito', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-Wapito-Signature'))) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
console.log(payload.event, payload.data);
res.sendStatus(200); // answer 2xx fast; do the real work in a queue
});
app.listen(Number(process.env.PORT ?? 3000));
Receivers in Java, C#, Go, Ruby, Kotlin and more are generated for every tutorial under whatsapp-bot, each with the same check.
Delivery, retries and back-off
A delivery succeeds when your endpoint answers any 2xx within 10 seconds — connection,
headers and body together. Anything else, including a timeout, a 3xx or a TLS error, is a
failed attempt, and the job is retried on a fixed schedule:
| Failed attempts | Next attempt after |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | — the job is marked failed and not retried |
Five attempts spread over roughly 2 hours 36 minutes, with the last wait of 12 hours reserved for
jobs whose attempt budget has been raised by hand. Retries carry the same X-Wapito-Delivery
value and the same event; each attempt is signed afresh, so t and the signature change. Every attempt — status code, duration
and the first 512 bytes of your response — is visible on the channel's Logs tab, which is
the first place to look when an event seems to be missing.
Delivery is at least once and not strictly ordered. Two deliveries can be in flight to the
same endpoint at once (the worker sends up to eight in parallel), and a retry can overtake a
newer event. Key your handler on the envelope id, keep a short-lived set of ids you have already
processed, and order by timestamp when order matters.
The circuit breaker
An endpoint that is plainly down should not burn its jobs' five attempts. After five consecutive failures to the same endpoint — scheme, host, port and path; the query string is ignored — the circuit opens for 60 seconds. While it is open, jobs for that endpoint are put back on the queue with their attempt count untouched; after a minute one delivery is let through as a probe, and if it succeeds the circuit closes. An outage on your side therefore costs you time, never events.
Answer fast, work later
Ten seconds is generous, and you should never come near it. Verify the signature, write the
payload to a queue or a database, return 200, and do the real work — replying, calling a CRM,
downloading media — from a worker. A handler that does its work inline will time out under
load, be retried, and do the work twice.
Testing an endpoint
POST /webhooks/{id}/test sends one
synthetic event of the kind you name and reports what happened, without queueing or retrying:
curl -X POST https://api.wapito.com/v1/webhooks/whk_01JRQ8F4X9N2K7YB3C5V6W8H0T/test \
-H "Authorization: Bearer $WAPITO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"event": "messages"}'
{
"delivered": true,
"status_code": 200,
"duration_ms": 148,
"response_excerpt": "{\"ok\":true}",
"signature": "t=1789459200123,v1=5f8a1c0b…c4d5",
"error": null
}
The signature in the response is the header that was actually sent, so a verifier that rejects
the test can be debugged against a known-good value. The same button lives on the Settings tab of
the dashboard.