Developer Guide · Guides
Webhooks
Webhooks push events from Inboxi to your server as signed HTTPS POST requests, so you can react to deliveries, failures and data changes without polling.
A webhook is a URL on your server plus a list of events. When one of those events happens in your workspace, Inboxi sends a JSON payload to the URL, signed with your secret. You can manage webhooks in the dashboard or through the API.
Create a webhook
From the dashboard
- 1Create an API key firstThe webhook is signed with the secret of your newest active API key, so make sure you have one and saved its secret.
- 2Open the Webhooks sub-tabGo to Settings › Developer Hub › API Keys and switch to the Webhooks sub-tab, then click + New Webhook.
- 3Enter the URL and pick eventsPaste your HTTPS endpoint, then choose All Events or individual events.
- 4Save the signing secretAfter Create, a panel shows the signing secret for this webhook. Store it on your server for verification.
- 5Send a testClick Test on the webhook to deliver a
test.pingevent. Use Pause/Activate to stop and resume deliveries.
- 1Webhooks reference tab.
- 2The request to create a webhook through the API.
- 3Available events, with a pointer to GET /api/v1/events for the live list.
From the API
/api/v1/webhooksCreate a webhook (permission: webhooks)curl -X POST https://inboxi.app/api/v1/webhooks \
-H "X-API-Key: $INBOXI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/inboxi/webhook",
"events": ["message.delivered", "message.failed", "external_data.received"]
}'{
"success": true,
"data": {
"id": 12,
"url": "https://api.example.com/inboxi/webhook",
"events": ["message.delivered", "message.failed", "external_data.received"],
"secret": "sk_live_…",
"isActive": true,
"createdAt": "2026-10-10T09:00:00.000Z"
}
}Omit events (or send ["*"]) to receive everything. Unknown event names are rejected. UsePATCH /api/v1/webhooks/:id to change url, events or isActive; the other management endpoints are listed in the API reference.
Events
GET /api/v1/events returns the list of event names you can subscribe to. Today these are the events Inboxi actively emits:
| Event | Fires when | Status |
|---|---|---|
message.sent | A message is sent through POST /api/v1/messages/send. | Emitted |
message.delivered | WhatsApp confirms a message reached the customer's device. | Emitted |
message.read | The customer opened the message (if read receipts are on). | Emitted |
message.failed | WhatsApp could not deliver; the payload includes error.code and error.details. | Emitted |
external_data.received | Your platform pushed external data (single or bulk). | Emitted |
test.ping | You click Test or call the test endpoint. | Emitted |
The list also contains message.received, conversation.created, conversation.closed,conversation.assigned, contact.created, contact.updated, order.created,order.updated, appointment.created and appointment.updated. You can subscribe to them now, but they are not delivered yet, so do not build critical flows on them.
message.delivered, message.read, message.failed) come from the WhatsApp Cloud API and cover outbound WhatsApp messages, including event messages. message.sent is skipped for those because your own API call already acknowledged the send.Payload
Every delivery is a POST with these headers and a JSON envelope:
| Header | Type | Required | Description |
|---|---|---|---|
X-Inboxi-Signature | string | optional | sha256=<hex HMAC of the raw body> |
X-Inboxi-Event | string | optional | The event name, e.g. message.delivered. |
X-Inboxi-Delivery | uuid | optional | Unique per delivery attempt. Useful for logging; a retry gets a new value. |
User-Agent | string | optional | Inboxi-Webhook/1.0 |
Events that relate to a chat are enriched with a contact block built from the chat: the name, phone and email, plus the externalUserId and customData your widget or SDK passed in identify(). That lets you map the event to your own user without a second API call.
{
"event": "message.failed",
"timestamp": "2026-10-10T09:31:12.481Z",
"data": {
"chatId": 4821,
"messageId": 99120,
"whatsappMessageId": "wamid.HBgM…",
"to": "201012345678",
"platform": "whatsapp_cloud",
"status": "failed",
"timestamp": "2026-10-10T09:31:11.000Z",
"error": { "code": 131026, "title": "Message undeliverable", "details": "…" },
"contact": {
"chatId": 4821,
"name": "Ahmed Ali",
"phone": "201012345678",
"email": "ahmed@example.com",
"externalUserId": "u_12345",
"customData": { "plan": "pro" },
"baseVisitorId": null
}
}
}{
"event": "external_data.received",
"timestamp": "2026-10-10T09:40:00.002Z",
"data": { "source": "academy", "dataType": "grades", "recordCount": 250 }
}The signing secret
- When a webhook is created, Inboxi copies the secret of your newest active API key (
sk_live_…) onto it, so most integrations manage a single secret. If you have no active key, a dedicatedwhsec_…secret is generated instead. - Any
secretyou send in the create request is ignored. Always read the secret from the create response (or the dashboard panel) and store it. - The secret is fixed per webhook. Rotating your API key does not change existing webhooks; recreate them to move to the new secret.
Verify signatures
Compute an HMAC-SHA256 of the raw request body with the secret and compare it to the hex value aftersha256= using a constant-time comparison. Capture the raw bytes before your framework parses JSON: re-serialising the parsed body can change whitespace or key order and break the hash.
const crypto = require('crypto');
const express = require('express');
const app = express();
// 1. Keep the raw body bytes: the signature is computed over them.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
function verifyInboxiSignature(req, secret) {
const header = req.get('X-Inboxi-Signature') || ''; // "sha256=<hex>"
const [algo, signature] = header.split('=');
if (algo !== 'sha256' || !signature) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(req.rawBody) // raw bytes, NOT JSON.stringify(req.body)
.digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/inboxi/webhook', (req, res) => {
if (!verifyInboxiSignature(req, process.env.INBOXI_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Replay protection: reject payloads older than 5 minutes
const age = Date.now() - new Date(req.body.timestamp).getTime();
if (Math.abs(age) > 5 * 60 * 1000) return res.status(401).send('Stale payload');
// Acknowledge fast, then do the work asynchronously
res.status(200).send('OK');
handleEvent(req.get('X-Inboxi-Event'), req.body.data);
});Replay protection
The envelope's timestamp is set when the delivery is built and is covered by the signature. Reject payloads more than five minutes away from your clock, as in the examples above. Keep your server time synced with NTP so legitimate deliveries are not rejected.
Retries and auto-disable
| Rule | Value |
|---|---|
| Success | Any 2xx response |
| Timeout per attempt | 10 seconds |
| Attempts per event | 3 in total: the first try, then retries after 2s and 4s |
| Failure counter | Every failed attempt counts; any success resets it to 0 |
| Auto-disable | After 10 consecutive failed attempts the webhook is paused |
- Respond with 200 quickly and process the event asynchronously. Slow handlers hit the 10-second timeout and trigger retries.
- Because of retries you may receive the same event more than once. Make handlers idempotent using data from the payload (for example
messageId+status), notX-Inboxi-Delivery. - A paused webhook shows Paused with its failure count and last error in the dashboard. Fix your endpoint, then click Activate (or
PATCHwith{"isActive": true}), which also resets the counter. - Events that happen while a webhook is paused are not queued for later.
Testing and debugging
- 1Expose a local endpointUse a tunnel such as ngrok or Cloudflare Tunnel to give your local server a public HTTPS URL, and register that URL.
- 2Send test.pingClick Test in the dashboard or call
POST /api/v1/webhooks/:id/test. The payload is signed like a real event. - 3Read the delivery log
GET /api/v1/webhooks/:id/logsreturns each attempt with the HTTP status, the first 1,000 characters of your response, duration and error. - 4Trigger a real eventPush a record to external data to get
external_data.received, or send an event message to a test number to get delivery receipts.