WASync · Developers

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

PUT /webhook
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"}'
200 response
{
  "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

EventFires whenDirection
message.receivedA WhatsApp message arrives on a granted connection (text or media).Inbound only — messages you send do not fire this.
message.statusThe delivery state of one of your outgoing messages changes.Outbound only.
connection.disconnectedA connection stopped working and needs its owner to pair again.Once per outage, not repeatedly while it stays broken.
connection.connectedA 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

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 id (the same value GET /messages returns as id). Dedupe and join on it — delivery is at-least-once.
  • message.wa_id — WhatsApp's own id, null when unknown. Support and debugging only, never a dedupe or join key.
  • from — international digits only, no +.
  • text is null for media-only messages; media_url / media_type are null for 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:

POST your webhook URL
{
  "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.

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. Everything else, including redirects, is a failure.
Attempts3 per event — immediate, then after 500 ms, then after 1 s.
Per-attempt timeout10 seconds. ACK fast and process async.
After the last failureThe event is dropped. There is no long redelivery queue.
DuplicatesPossible. Dedupe on message.id.
OrderingNot guaranteed. Order by timestamp if it matters.
Signature on retriesIdentical 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

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
}

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 with GET /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 402 on sends.
  • Does your endpoint answer 2xx within 10 seconds? Slow handlers look like failures and burn all three attempts.
  • Is it a qr connection? cloud_api connections emit no webhooks.
  • Only inbound messages fire message.received — your own sends never do.

On this page