Webhooks
WASync POSTs a signed JSON event to your endpoint when a message arrives on one of your connections. Set the endpoint with PUT /api/v1/webhook and you get the signing secret back. This page is the full delivery contract.
Requirements
Four things must all be true before any event is delivered:
- •A delivery URL is set — HTTPS on a public host. Plain
http, localhost and private-network addresses are rejected. With an API key, set it yourself withPUT /api/v1/webhook(or on developers.wasync.app/keys); a third-party OAuth app setswebhook_urlon the app instead. - •The credential includes the
whatsapp.eventsscope and covers the connection. - •The connection’s license is active — for Bitrix24 connections, that means the per-connection API add-on; for standalone workspaces, the connection’s own license (trial or paid). An expired license stops events and causes 402 on sends. Current pricing and activation: developers.wasync.app/billing.
- •The webhook subscription is active. Its
secretsigns every delivery.PUT /api/v1/webhookreturns it when the endpoint is set, andPOST /api/v1/webhook/rotateissues a new one;GET /api/v1/webhookdeliberately never returns it. It is also visible at developers.wasync.app → API keys (for OAuth app subscriptions: → Webhooks, per app × workspace; does not rotate on re-consent).
Event catalog
| Event | Fires when | Direction |
|---|---|---|
message.received | A WhatsApp message arrives on a granted connection (text or media). | Inbound only — messages your side sends do not fire this event. |
message.status | The delivery state of one of your outgoing messages changes — for rendering sent/delivered/read ticks (and send failures) in real time. | Outbound only — the messages you sent via the API. |
connection.disconnected | A connection stopped working and needs its owner to scan a fresh QR code. Nothing will send or arrive on that number until they do. | Fires once, when the connection breaks — not repeatedly while it stays broken. |
connection.connected | A connection that had broken is working again. | Fires once, and only for a connection you were told had broken. |
Four event types today; new types will be announced on the changelog before they ship, and unknown event values should be ignored, not treated as errors.
Webhooks set with PUT /api/v1/webhook receive all four event types from the start. For OAuth app subscriptions, those created before Jul 24 2026 receive only message.received: to also receive message.status, re-consent (Bitrix apps: re-authorize in the Bitrix panel; standalone: re-authorize at developers.wasync.app/authorize) or ask support to enable it on your existing subscription.
Connection lifecycle
If you run connections on behalf of your own customers, these two events are what keep you ahead of them. 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 simply ages out. When that happens the number goes quiet in both directions, and without an event the first person to notice is your customer.
{
"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
}Each event fires once per outage. connection.disconnected is sent at the moment the connection breaks, not on a timer while it stays broken, so a number left unscanned for a week produces one event rather than a stream. connection.connected is sent when it starts working again, and only for a connection you were told had broken.
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-scan is needed — fetch the QR itself over your authenticated GET /api/v1/connections/{id} and show it to the number's owner, exactly as you do during first setup.
reason is a short machine-readable hint for your logs. Treat it as advisory: new values can appear, and the field that drives your UI is needs_reconnect.
Like message.status, these events reach only subscriptions signed up for them. Subscriptions created before Aug 10 2026 keep the list they were created with — re-authorize and they pick up the connection events, or ask support to add them to your existing subscription.
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 message id (the same valueGET /messagesreturns asid); dedupe and join on it (retries can deliver duplicates). - •
message.wa_id— WhatsApp’s own id for the same message (nullwhen unknown). For support and debugging — never a dedupe or join key. - •
from— sender, international digits only (no+). - •
text—nullfor media-only messages;media_url/media_type—nullfor text messages or when the media file isn’t publicly fetchable. - •
timestamp— epoch milliseconds (number), not an ISO string.
A message.status delivery updates the state of one of your outgoing messages:
{
"event": "message.status",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"status": "read",
"timestamp": 1766138641000
}
}- •
message.id— WASync’s stable id: the exact valuePOST /messagesreturned asmessageId. Join on it to update the right message. - •
message.wa_id— WhatsApp’s own id (whatPOST /messagesreturns aswaMessageId,nullwhen unknown). Support and debugging only. - •
status— one ofsent,delivered,read,failed. Only meaningful transitions are delivered; a message may skip straight to a later state. Ordering isn’t guaranteed — treat it as a monotonic ladder and ignore any status that moves a message backwards.
Which id do I join on?
message.id — always. It is identical to the messageId from POST /messages and the id from GET /messages. Store that value when you send, then match message.status events against it.
message.wa_id (waMessageId in REST) is WhatsApp’s own id, e.g. [email protected]_3EB0…. It can be null and it is not a WASync key — quote it in support tickets, don’t index on it.
Verify the signature
Every delivery carries two headers: X-WASync-Timestamp (epoch ms) and X-WASync-Signature: sha256=<hex> = HMAC-SHA256(`${timestamp}.${rawBody}`, subscription_secret). Compute it over the raw request body (before any JSON parsing) and compare in constant time. The timestamp binds the signature to a moment in time — reject deliveries older than ~5 minutes to block 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 response. Everything else (including redirects) is a failure. |
| Attempts | 3 per event — immediate, then after 500 ms, then after 1 s (exponential backoff). |
| Per-attempt timeout | 10 seconds — ACK fast and process async. |
| After the last failure | The event is dropped — there is no long redelivery queue. Missed messages remain readable via GET /messages, so poll to backfill after downtime. |
| Duplicates | Possible (a retry after a slow 2xx boundary) — dedupe on message.id. |
| Ordering | Not guaranteed — order by timestamp if it matters. |
| Signature on retries | Identical body and signature are resent — safe to verify each attempt the same way. |
Status ladder & monotonic updates
message.status values form a one-way ladder:
| Status | Rank | Meaning |
|---|---|---|
sent | 1 | Accepted by WhatsApp servers. |
delivered | 2 | Delivered to the recipient’s device. |
read | 3 | Read by the recipient (read receipts enabled). |
failed | — | Terminal send failure. Does not follow read. |
read before delivered (late delivery), and duplicate events are possible. Never move a message backwards in your UI.Reconciliation — poll as a safety net
Webhook deliveries are attempted 3 times (immediate, 500 ms, 1 s) and then dropped. There is no long redelivery queue. This means your webhook handler is the fast path, not the guarantee.
To catch everything, run a background reconciliation loop that polls GET /messages?connectionId=… every 30–60 seconds and upserts any rows your webhook handler has not yet seen. Use the cursor for efficient incremental fetches. This loop also backfills status updates that arrived while your webhook endpoint was down.
Pattern
Webhooks = real-time UI updates. Polling = correctness guarantee. Run both.
Not receiving events?
- •Your delivery URL is HTTPS on a public host? Localhost, private IPs and
http://are rejected — use a tunnel (e.g. Cloudflare Tunnel) during development. Check what is actually stored withGET /api/v1/webhook. - •Your credential carries the
whatsapp.eventsscope (an OAuth grant must have been approved with it), and the message arrived on a connection it covers? - •The connection’s license is still active? For Bitrix24 connections that means the per-connection API add-on; for standalone workspaces, the connection’s own license (trial or paid). Expired license = no events and a
402on sends. Pricing and activation: developers.wasync.app/billing. - •Your endpoint answers
2xxwithin 10 seconds? Slow handlers look like failures and burn the 3 attempts. - •Only inbound messages fire events — sends from your own side never do.