Webhooks
One signed endpoint per account. Signature verification, the event catalog, the retry policy and secret rotation.
Point WASync at your endpoint and you get a signing secret back. Uses
whatsapp.events. No app registration and no consent step is involved.
One webhook slot per account
An account has exactly one webhook URL. PUT /webhook sets it — calling it
again with a different URL moves the endpoint rather than adding a second one,
and there is no delete. This matters as soon as more than one thing wants
events: an n8n trigger and your own backend cannot both own
the slot. Fan out on your side.
Set the endpoint
curl -X PUT https://developers.wasync.app/api/v1/webhook \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/wasync/webhook"}'{
"url": "https://example.com/wasync/webhook",
"events": [
"message.received",
"message.status",
"connection.disconnected",
"connection.connected"
],
"secret": "whsec_3f9a…"
}Store the secret. It is issued here because this is the moment you need it
to write your verification code, and GET /webhook will not return it again —
that endpoint reports { url, events, secretSet }, deliberately without the
secret, because a read path gets logged and pasted into tickets. Lost it? Call
POST /webhook/rotate for a new one. The URL must be HTTPS on a publicly
reachable host; http, localhost and private ranges are rejected with
400 invalid_webhook.
Rotation has no overlap window: the old secret dies the instant the new one is issued. Deploy the new secret promptly — the retry policy below covers a rollout measured in seconds, not hours.
Event catalog
| Event | Fires when | Direction |
|---|---|---|
message.received | A WhatsApp message arrives on a granted connection (text or media). | Inbound only — messages you send do not fire this. |
message.status | The delivery state of one of your outgoing messages changes. | Outbound only. |
connection.disconnected | A connection stopped working and needs its owner to pair again. | Once per outage, not repeatedly while it stays broken. |
connection.connected | A connection that had broken is working again. | Once, and only for a connection you were told had broken. |
Ignore unknown event values rather than treating them as errors — new types
are announced on the changelog before they ship.
Events fire for qr connections only. cloud_api connections emit no
webhooks today; poll GET /messages and GET /connections/{id} for those.
Payload
{
"event": "message.received",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"from": "40700000000",
"text": "Hello!",
"media_url": null,
"media_type": null,
"timestamp": 1766138640000
}
}message.id— WASync's stable id (the same valueGET /messagesreturns asid). Dedupe and join on it — delivery is at-least-once.message.wa_id— WhatsApp's own id,nullwhen unknown. Support and debugging only, never a dedupe or join key.from— international digits only, no+.textisnullfor media-only messages;media_url/media_typearenullfor text messages or when the file is not publicly fetchable.timestamp— epoch milliseconds as a number, not an ISO string.
A message.status delivery updates one of your outgoing messages:
{
"event": "message.status",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"status": "read",
"timestamp": 1766138641000
}
}Verify the signature
Every delivery carries X-WASync-Timestamp (epoch ms) and
X-WASync-Signature: sha256=<hex>, which is
HMAC-SHA256(timestamp + "." + rawBody, secret). Compute it over the raw
body — before any JSON parsing — and compare in constant time. Reject deliveries
whose timestamp is more than ~5 minutes old, which is what blocks replays.
import crypto from "node:crypto";
// rawBody MUST be the exact bytes you received (verify BEFORE JSON.parse).
export function verifyWASync(headers, rawBody, secret) {
const ts = headers["x-wasync-timestamp"];
const sig = headers["x-wasync-signature"]; // "sha256=<hex>"
const expected =
"sha256=" +
crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
return Boolean(sig) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}import express from "express";
import { verifyWASync } from "./verify.js";
const app = express();
// Capture the RAW body — signature verification needs the exact bytes.
app.post("/webhooks/wasync", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWASync(req.headers, req.body.toString("utf8"), process.env.WASYNC_WEBHOOK_SECRET)) {
return res.status(401).end();
}
// Reject stale deliveries (replay window) — 5 minutes is a sane tolerance.
if (Math.abs(Date.now() - Number(req.headers["x-wasync-timestamp"])) > 5 * 60_000) {
return res.status(401).end();
}
res.status(200).end(); // ACK fast — under 10s — then process async
const { message } = JSON.parse(req.body.toString("utf8"));
// ... your logic (dedupe on message.id — retries can deliver duplicates)
});Delivery & retry policy
| Property | Value |
|---|---|
| Success criterion | Any 2xx. Everything else, including redirects, is a failure. |
| Attempts | 3 per event — immediate, then after 500 ms, then after 1 s. |
| Per-attempt timeout | 10 seconds. ACK fast and process async. |
| After the last failure | The event is dropped. There is no long redelivery queue. |
| Duplicates | Possible. Dedupe on message.id. |
| Ordering | Not guaranteed. Order by timestamp if it matters. |
| Signature on retries | Identical body and signature are resent — verify each attempt the same way. |
Status ladder
message.status values form a one-way ladder: sent (1) → delivered (2) →
read (3), with failed as a terminal outcome that does not follow read.
Apply updates monotonically. Only apply a new status if its rank is higher
than the one you have stored. A message can arrive with read before
delivered (a late delivery), and duplicate events are possible. Never move a
message backwards in your UI.
Reconciliation — poll as a safety net
Deliveries are attempted three times and then dropped, which makes your webhook
handler the fast path, not the guarantee. Run a background loop that polls
GET /messages?connectionId=… every 30–60 seconds and upserts anything the
handler has not seen, using the cursor for incremental fetches. The same loop
backfills status updates that arrived while your endpoint was down.
Webhooks = real-time UI updates. Polling = correctness. Run both.
Connection lifecycle
{
"event": "connection.disconnected",
"connection_id": "conn_8f2a…",
"connection": {
"id": "conn_8f2a…",
"phone_number": "393331234567",
"label": "Studio Rossi",
"status": "disconnected",
"needs_reconnect": true,
"license_status": "active",
"license_expires": "2027-08-08T09:00:00.000Z",
"reason": "AUTH_LOST"
},
"timestamp": 1786000000000
}A qr connection can stop working for reasons outside anyone's control — the
phone is switched off for a day, WhatsApp is opened on another device, the
session ages out. The number then goes quiet in both directions, and without an
event the first person to notice is your customer.
The QR code is deliberately not in the payload. A WhatsApp QR is a
scan-to-login credential: anyone who reads it can take over the session. The
event tells you a re-pair is needed — fetch the QR itself over your
authenticated GET /connections/{id} and show it to the number's owner, exactly
as during first setup. Drive your UI off needs_reconnect; treat reason as an
advisory log hint, since new values can appear.
Detection latency: a disconnect is found by a 5-minute status probe (~5 min). When the session looks recoverable an automatic heal is attempted first, so that path takes ~15–20 minutes.
Not receiving events?
- Is the delivery URL HTTPS on a public host? Localhost, private IPs and
http://are rejected. Use a tunnel during development, and check what is actually stored withGET /webhook. - Does the credential carry
whatsapp.events, and does it cover the connection the message arrived on? - Is the connection's licence still active? An expired licence means no events
and
402on sends. - Does your endpoint answer
2xxwithin 10 seconds? Slow handlers look like failures and burn all three attempts. - Is it a
qrconnection?cloud_apiconnections emit no webhooks. - Only inbound messages fire
message.received— your own sends never do.