View as Markdown

Status webhooks

Declare a URL in your dashboard, under Developers. Every state change of a message you sent is pushed there.

One URL per key: your local tunnel on the test key, your server on the live key. You never have to flip a setting between two runs.

The payload

{
  "event": "whatsapp.message.status",
  "id": "evt_9f2c1a3b4d5e6f708192",
  "created_at": "2026-09-02T10:11:12.000Z",
  "data": {
    "message_id": "wamid.HBgMMjI1MDcwMDAwMDAw",
    "status": "delivered",
    "recipient": "+2250700000000",
    "client_ref": "invoice-2026-0912",
    "error": null,
    "error_code": null
  }
}

status is sent, delivered, read or failed. On failure, error_code carries Meta's code.

client_ref is the reference you passed when sending, returned untouched.

Headers

HeaderContent
X-Fiitsa-Signature-256sha256=<hmac>
X-Fiitsa-TimestampUnix timestamp in seconds
X-Fiitsa-DeliveryUnique delivery identifier
X-Fiitsa-Eventwhatsapp.message.status

Verify the signature

The signature is the HMAC-SHA256 of "{timestamp}.{raw body}", using the secret shown in your dashboard.

The timestamp is part of the signed string. That is what makes a replay detectable: without it, a captured delivery would stay replayable forever. Reject anything older than five minutes.

const crypto = require("crypto");

app.post("/webhooks/fiitsa", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("X-Fiitsa-Signature-256");
  const timestamp = req.get("X-Fiitsa-Timestamp");

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(401);

  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.FIITSA_WEBHOOK_SECRET)
    .update(timestamp + "." + req.body.toString())
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString());
  res.sendStatus(200);
});

Use a constant-time comparison, not ===: a naive comparison leaks the expected signature, character by character, through response timing.

Delivery

One attempt, no retry. Respond quickly with any 2xx and process afterwards: a slow server is treated as a failing server.

If you miss an event, the message state stays queryable through the API.

Testing

The Send a test button in your dashboard posts a real, signed event to your URL, then shows what your server replied: HTTP status, latency, response body. The payload is identical to production, apart from a "test": true field.

URL constraints

HTTPS required, on a public domain. Literal IP addresses, localhost and internal suffixes are rejected at save time. Redirects are not followed.