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
| Header | Content |
|---|---|
X-Fiitsa-Signature-256 | sha256=<hmac> |
X-Fiitsa-Timestamp | Unix timestamp in seconds |
X-Fiitsa-Delivery | Unique delivery identifier |
X-Fiitsa-Event | whatsapp.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.