Skip to main content
Webhooks let your code react to events from your WhatsApp numbers in real time. Every delivery is signed, retried, and recorded so you can debug from the dashboard.
The Webhooks dashboard lives at wassist.app/developers/webhooks. Create one there, copy the signing secret, and you’re done.

How a delivery works

Behind the scenes:
  1. The event is committed to our outbox as a WebhookDelivery with a stable UUID.
  2. A worker POSTs the JSON body to your URL.
  3. We record an attempt row with status code, duration, and response body.
  4. On 5xx or network errors we retry up to 3 times with exponential backoff (10s, 30s, 90s).
  5. After 20 consecutive failures the webhook is auto-disabled and you get a banner in the dashboard.

Headers

HeaderPurpose
Content-TypeAlways application/json.
User-AgentWassist-Webhook/1.0
X-Wassist-EventThe event name, e.g. message.received.
X-Wassist-DeliveryStable UUID. Use this as your idempotency key. It does not change across retries.
X-Wassist-TimestampUnix seconds. The time we signed the request.
X-Wassist-Signaturet=<ts>,v1=<hex hmac sha256> — Stripe-style.

Verifying signatures

Compute HMAC-SHA256 over the string <timestamp>.<raw body> using the webhook’s signing secret and compare it in constant time to the v1 component.
import crypto from "node:crypto";

app.post("/webhooks/wassist", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.header("x-wassist-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const signedPayload = `${parts.t}.${req.body.toString("utf-8")}`;
  const expected = crypto
    .createHmac("sha256", process.env.WASSIST_WEBHOOK_SECRET!)
    .update(signedPayload)
    .digest("hex");

  const ok = crypto.timingSafeEqual(
    Buffer.from(parts.v1, "hex"),
    Buffer.from(expected, "hex"),
  );
  if (!ok) return res.status(400).send("bad signature");

  // Replay protection: reject events more than 5 minutes old.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
    return res.status(400).send("stale");
  }

  // Idempotency: drop duplicate deliveries.
  const deliveryId = req.header("x-wassist-delivery")!;
  if (await alreadyProcessed(deliveryId)) return res.status(200).send("ok");

  await handle(JSON.parse(req.body.toString("utf-8")));
  await markProcessed(deliveryId);
  res.status(200).send("ok");
});

Event catalog

message.received

Fired when a customer sends a message to one of your numbers.
{
  "event": "message.received",
  "timestamp": "2026-06-23T16:48:00.000Z",
  "phoneNumber": "+447700900100",
  "from": "+447700900200",
  "contact": { "name": "Alex", "phoneNumber": "+447700900200" },
  "message": {
    "id": "01HXYZ...",
    "waId": "wamid.HBgL...",
    "body": "Hey, can I add another item to my order?",
    "media": [],
    "buttons": []
  },
  "conversationId": "5a3f...e2"
}

test.ping

Triggered by the dashboard Send test event button. Same envelope as a real event with a stub payload — use it to validate signature verification in CI.

Best practices

  • Idempotency. Store the X-Wassist-Delivery ID and reject duplicates. We use the same ID across all retries.
  • Respond fast. Send a 2xx within 10 seconds and do real work asynchronously. We treat slow responses as failures and retry.
  • Replay protection. Reject events where X-Wassist-Timestamp is more than 5 minutes from now.
  • Rotate secrets. From the dashboard, Rotate secret generates a new secret. The old one stops working immediately.
  • Local development. Use the CLI: wassist listen tunnels real events to localhost without exposing a public URL.

Replaying deliveries

Every delivery is stored with its full payload. From the dashboard you can:
  • Filter deliveries by status (all / failed / succeeded).
  • Open a delivery to see request headers, response body, and the timeline of every retry attempt.
  • Click Replay to send a fresh copy of the same payload (it gets a new X-Wassist-Delivery ID).
You can also replay programmatically via POST /api/v1/webhook-deliveries/{id}/replay/.