# Webhooks Part of the InboxWA API reference: https://inboxwa.online/docs/api . Full reference as one document: https://inboxwa.online/docs/api.md . OpenAPI: https://inboxwa.online/openapi.json Register one HTTPS endpoint per workspace and choose the events. Webhooks are part of Growth (five core events) and Scale (all events); Starter has none. Each event is a POST with a JSON body in this envelope: **Envelope** ```json { "event": "message_status", "sent_at": "2026-10-02T09:22:49.000Z", "correlation_id": "iwa_m1x2y3z4_a1b2c3d4e5f6", "actor": "inboxwa", "data": { } } ``` | Header | Value | | --- | --- | | `X-Webhook-Event` | The event name. | | `X-Webhook-Timestamp` | Unix seconds when it was sent. | | `X-Correlation-Id` | Same as correlation_id. Unique per event; use it to de-duplicate. | | `X-Webhook-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your signing secret. | ## Verify the signature Compute the HMAC over the exact bytes you received, before any JSON parsing, and compare in constant time. Reject the request if it does not match. The signature covers the body only. The `X-Webhook-Timestamp` header is not signed, so do not rely on it. **Verifying** ### Node.js ```javascript const crypto = require("crypto"); // rawBody must be the exact bytes received (express.raw), not re-serialised JSON function verify(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(header || ""); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ### Python ```python import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(header or "", expected) ``` ### PHP ```php function verify(string $raw, ?string $header, string $secret): bool { $expected = "sha256=" . hash_hmac("sha256", $raw, $secret); return hash_equals($expected, $header ?? ""); } ``` ## Reject replays A valid request that someone captured can be sent to you again. `sent_at` sits inside the signed body, so it cannot be altered without breaking the signature. Reject an event whose `sent_at` is more than five minutes old, and remember each `correlation_id` you have processed so a repeat is ignored. **After the signature has been verified** ```javascript const event = JSON.parse(rawBody); const age = Date.now() - Date.parse(event.sent_at); if (!(age > -60_000 && age < 5 * 60_000)) return res.status(400).end(); // too old, or from the future if (await alreadyProcessed(event.correlation_id)) return res.status(200).end(); // a repeat await markProcessed(event.correlation_id); // a database or Redis in production ``` ## Delivery - Answer with any 2xx quickly. Do slow work after responding. - Up to three attempts: the first at once, then after waits of 1 and 4 seconds, on network errors and 5xx responses only. A 4xx is a rejection and is not retried. Each attempt times out after 8 seconds. Nothing is retried after that. - **An event that fails every attempt is not delivered again.** If your endpoint can be down for longer than a few seconds, look for failures in the delivery log (`GET /api/webhook/outbound/deliveries`), and catch up by reading the same information from the API, for example `GET /api/reporting/v1/messages` with `since` set to when the outage began (Scale plan). - Delivery happens after the message is handled, so a slow endpoint never delays a WhatsApp reply. - Events can arrive more than once and out of order. De-duplicate on `correlation_id` and order by the timestamps in `data`. - Use `POST /api/webhook/outbound/test` to send a signed test event, and `GET /api/webhook/outbound/deliveries` to see what your endpoint answered. - Rotate the signing secret with `POST /api/webhook/outbound/rotate-secret`. The new secret is shown once.