WASync · Developers
Reference

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 with PUT /api/v1/webhook (or on developers.wasync.app/keys); a third-party OAuth app sets webhook_url on the app instead.
  • The credential includes the whatsapp.events scope 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 secret signs every delivery. PUT /api/v1/webhook returns it when the endpoint is set, and POST /api/v1/webhook/rotate issues a new one; GET /api/v1/webhook deliberately 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

EventFires whenDirection
message.receivedA WhatsApp message arrives on a granted connection (text or media).Inbound only — messages your side sends do not fire this event.
message.statusThe 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.disconnectedA 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.connectedA 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.

POST your webhook_url
{
  "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

POST your webhook_url
{
  "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 value GET /messages returns as id); dedupe and join on it (retries can deliver duplicates).
  • message.wa_id — WhatsApp’s own id for the same message (null when unknown). For support and debugging — never a dedupe or join key.
  • from — sender, international digits only (no +).
  • textnull for media-only messages; media_url/media_typenull for 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:

POST your webhook_url
{
  "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 value POST /messages returned as messageId. Join on it to update the right message.
  • message.wa_id — WhatsApp’s own id (what POST /messages returns as waMessageId, null when unknown). Support and debugging only.
  • status — one of sent, 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.

verify.js
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));
}
receiver (express)
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

PropertyValue
Success criterionAny 2xx response. Everything else (including redirects) is a failure.
Attempts3 per event — immediate, then after 500 ms, then after 1 s (exponential backoff).
Per-attempt timeout10 seconds — ACK fast and process async.
After the last failureThe event is dropped — there is no long redelivery queue. Missed messages remain readable via GET /messages, so poll to backfill after downtime.
DuplicatesPossible (a retry after a slow 2xx boundary) — dedupe on message.id.
OrderingNot guaranteed — order by timestamp if it matters.
Signature on retriesIdentical 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:

StatusRankMeaning
sent1Accepted by WhatsApp servers.
delivered2Delivered to the recipient’s device.
read3Read by the recipient (read receipts enabled).
failedTerminal send failure. Does not follow read.
Apply updates monotonically. Only apply a new status if its rank is higher than the one you already have stored. A message may arrive with 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 with GET /api/v1/webhook.
  • Your credential carries the whatsapp.events scope (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 402 on sends. Pricing and activation: developers.wasync.app/billing.
  • Your endpoint answers 2xx within 10 seconds? Slow handlers look like failures and burn the 3 attempts.
  • Only inbound messages fire events — sends from your own side never do.