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

  1. 1
    Create an API key first
    The webhook is signed with the secret of your newest active API key, so make sure you have one and saved its secret.
  2. 2
    Open the Webhooks sub-tab
    Go to Settings › Developer Hub › API Keys and switch to the Webhooks sub-tab, then click + New Webhook.
  3. 3
    Enter the URL and pick events
    Paste your HTTPS endpoint, then choose All Events or individual events.
  4. 4
    Save the signing secret
    After Create, a panel shows the signing secret for this webhook. Store it on your server for verification.
  5. 5
    Send a test
    Click Test on the webhook to deliver a test.ping event. Use Pause/Activate to stop and resume deliveries.
inboxi.app/settings/developer
The Webhooks tab of the Developer Hub is a quick reference for the webhook API and event names.
  1. 1Webhooks reference tab.
  2. 2The request to create a webhook through the API.
  3. 3Available events, with a pointer to GET /api/v1/events for the live list.

From the API

POST/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"]
  }'
201 Created
{
  "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:

EventFires whenStatus
message.sentA message is sent through POST /api/v1/messages/send.Emitted
message.deliveredWhatsApp confirms a message reached the customer's device.Emitted
message.readThe customer opened the message (if read receipts are on).Emitted
message.failedWhatsApp could not deliver; the payload includes error.code and error.details.Emitted
external_data.receivedYour platform pushed external data (single or bulk).Emitted
test.pingYou 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.

Delivery receipts (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:

HeaderTypeRequiredDescription
X-Inboxi-Signaturestringoptionalsha256=<hex HMAC of the raw body>
X-Inboxi-EventstringoptionalThe event name, e.g. message.delivered.
X-Inboxi-DeliveryuuidoptionalUnique per delivery attempt. Useful for logging; a retry gets a new value.
User-AgentstringoptionalInboxi-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.

message.failed
{
  "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
    }
  }
}
external_data.received
{
  "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 dedicated whsec_… secret is generated instead.
  • Any secret you 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

RuleValue
SuccessAny 2xx response
Timeout per attempt10 seconds
Attempts per event3 in total: the first try, then retries after 2s and 4s
Failure counterEvery failed attempt counts; any success resets it to 0
Auto-disableAfter 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), not X-Inboxi-Delivery.
  • A paused webhook shows Paused with its failure count and last error in the dashboard. Fix your endpoint, then click Activate (or PATCH with {"isActive": true}), which also resets the counter.
  • Events that happen while a webhook is paused are not queued for later.

Testing and debugging

  1. 1
    Expose a local endpoint
    Use a tunnel such as ngrok or Cloudflare Tunnel to give your local server a public HTTPS URL, and register that URL.
  2. 2
    Send test.ping
    Click Test in the dashboard or call POST /api/v1/webhooks/:id/test. The payload is signed like a real event.
  3. 3
    Read the delivery log
    GET /api/v1/webhooks/:id/logs returns each attempt with the HTTP status, the first 1,000 characters of your response, duration and error.
  4. 4
    Trigger a real event
    Push a record to external data to get external_data.received, or send an event message to a test number to get delivery receipts.