Webhooks and Event Delivery

Subscribe to Wapito events, verify the HMAC signature, respond fast enough to avoid retries, and understand the delivery and back-off guarantees.

Updated webhookseventssecurity

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

EventWhen it firesData
messagesEvery inbound and outbound message, including ones sent from the phone.The message object, with from_me set.
messages.editedA message was edited.message_id, chat_id, body, timestamp
messages.deletedA message was deleted for everyone.message_id, chat_id, from, timestamp
messages.statusA delivery receipt: failed, pending, sent, delivered, read or played.message_id, chat_id, recipient, status, ack, timestamp
messages.reactionsA reaction was added or removed.message_id, chat_id, from, emoji, timestamp
chatsA chat was archived or unarchived.action, chat_id, archived
contactsA contact was seen for the first time, or its phone, LID or name changed.action, id, phone, lid, name
groupsThe channel joined or left a group, or a subject, description or picture changed.action, group_id, subject, description, picture
groups.participantsParticipants added, removed, promoted or demoted, or a join request.group_id, action, participants[], by
presencesOnline, offline, typing and recording updates.chat_id, presence, last_seen
pollsA vote was cast, changed, or could not be decrypted.poll_message_id, chat_id, voter, selected_options[], failed
callsAn incoming call was received, accepted or rejected.call_id, from, status, is_video, is_group
labelsBusiness labels created, deleted, or attached to a chat (NOWEB engine).action, label, chat_id
channelThe 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

HeaderValue
Content-Typeapplication/json
User-AgentWapito-Webhooks/1.0 — allow-list it if your edge filters bots.
X-Wapito-EventThe event name, so a router can dispatch without parsing the body.
X-Wapito-DeliveryThe delivery job id. Every retry of the same event carries the same value.
X-Wapito-ChannelThe channel id.
X-Wapito-Signaturet=<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:

  1. Parse t and v1 out of the header.
  2. 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 fresh t.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) over the exact bytes you received — before any JSON parsing, re-serialising or whitespace changes.
  4. Compare it to v1 with 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 attemptsNext attempt after
11 minute
25 minutes
330 minutes
42 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.

Try it on your own number

Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.