Developer docs

Webhooks

Get real-time notifications the instant something happens in your BotInbox workspace — new messages, conversation updates, contact changes, and more — delivered straight to your server.

01Overview

Webhooks let BotInbox push events to your server the moment something happens — whereas with the REST API you pull data on your own schedule. Instead of polling for new messages, you register an HTTPS endpoint once and BotInbox calls it in real time with a signed JSON payload for every event you subscribe to.

Typical uses: sync conversations into your CRM, trigger an SMS when a customer replies, mirror contacts into your own database, or kick off internal workflows the second a conversation closes.

02Quick start

  1. 1Go to https://botinbox.co.uk/manage/webhooks in your dashboard.
  2. 2Add an endpoint — a public URL on your server that accepts POST requests. HTTPS is required.
  3. 3Select the events you want to receive (or subscribe to * for all of them).
  4. 4Copy the signing secret — it is shown once, at creation. Store it securely (e.g. an environment variable); you will use it to verify every delivery.

03Request format

Every delivery is an HTTP POST to your endpoint with these headers:

  • X-BotInbox-Event — the event name (e.g. message.created)
  • X-BotInbox-Signature — hex-encoded HMAC-SHA256 of the raw request body, keyed with your webhook secret
  • X-BotInbox-Delivery — a unique id for this delivery, the same on every retry of it. Store it and skip any id you have already handled.
  • Content-Type — always application/json
  • Any custom headers you configured on the endpoint — use these when your receiver requires a static credential such as Authorization: Bearer …. Up to 10 per endpoint. Reserved names (Content-Type, X-BotInbox-*, Host and hop-by-hop headers) cannot be overridden, and stored values are masked whenever the configuration is read back.

A webhook can also be restricted to specific channels — useful when an external bot should only see one WhatsApp number. Leave the selection empty to receive events from every channel. Events that aren’t tied to a channel ( contact.*, channel.*) are always delivered.

For a mailbox with several addresses (aliases), a webhook can also be narrowed to some of its email addresses. Incoming email counts when it was sent (To or Cc) to a listed address; replies count when they go out from one. Events from other channels are not affected by this filter.

For a WhatsApp number connected by QR with group chats switched on, choose which group messages reach the endpoint: all of them, only those that @mention or reply to the number (mentionsMe in the message metadata), or none. Direct chats are always delivered.

Request headers
POST /webhooks/botinbox HTTP/1.1
Host: example.com
Content-Type: application/json
X-BotInbox-Event: message.created
X-BotInbox-Signature: 8f7d2f0c1a5e0b9d4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c
X-BotInbox-Delivery: 3f6c2b1e-8d4a-4c7e-9b2f-1a5d6e7f8a9b

# ...plus any custom headers you configured on the endpoint:
Authorization: Bearer your-token

The JSON body always has the same envelope — the event name, an event-specific data object, and an ISO-8601 timestamp:

Example payload — message.created
{
  "event": "message.created",
  "data": {
    "message": {
      "id": "msg_9f2c1e7a",
      "conversationId": "conv_abc123",
      "type": "INCOMING",
      "body": "",
      "attachments": "[{\"url\":\"/api/media/cm1x…\",\"name\":\"whatsapp-audio.ogg\",\"kind\":\"audio\",\"voice\":true}]",
      "metadata": "{\"whatsappMediaKind\":\"audio\",\"media\":{\"status\":\"pending\"}}",
      "media": {
        "status": "pending",
        "transcript": null,
        "attachments": [{
          "id": "cm1x…",
          "kind": "audio",
          "voice": true,
          "url": "https://botinbox.co.uk/api/media/cm1x…",
          "name": "whatsapp-audio.ogg",
          "mimeType": "audio/ogg; codecs=opus",
          "size": 12345,
          "durationMs": 4200
        }]
      },
      "createdAt": "2026-01-01T00:00:00.000Z"
    },
    "conversationId": "conv_abc123",
    "burstId": "burst_…",
    "burstPending": true,
    "recentWindow": [{ "id": "msg_…", "hasText": false, "mediaKind": "image", "attachments": [] }],
    "context": {
      "orgId": "org_…", "channelId": "ch_…", "conversationId": "conv_abc123", "messageId": "msg_9f2c1e7a", "contactId": "ct_…",
      "organization": { "id": "org_…", "name": "IT Bot Solution", "brandName": null, "slug": "itbot" },
      "channel": { "id": "ch_…", "name": "HK - PA number", "type": "WHATSAPP", "connectionType": "baileys", "phoneNumber": "852…", "status": "connected", "verifiedName": "IT Bot" },
      "contact": { "id": "ct_…", "name": "Him", "phone": "852…", "email": null, "tags": ["owner"], "customFields": { "access": "master" }, "verificationStatus": "VERIFIED" },
      "conversation": { "status": "OPEN", "assignedToId": null, "subject": null, "priority": "NORMAL" }
    }
  },
  "timestamp": "2026-01-01T00:00:00.000Z"
}

04Event catalog

The data field of the payload depends on the event:

Eventdata payload
conversation.createdThe conversation object
conversation.updatedThe conversation object
conversation.closedThe conversation object
conversation.assignedThe conversation object (assignee in assignedToId)
conversation.burst_ready{ burstId, orgId, conversationId, channelId, contactId, windowType, quietMs, closedReason, messages, combinedText, instructionMessageId, instructionText, instructionPending, assets, messageIds, context } — several inbound WhatsApp or LINE messages from one contact, grouped n8n-style (“look and wait”): every message resets the gap. The gap is short (default 3 s) for a run of text and for files once the instruction has arrived, long (default 15 s) while bare files are waiting for the text or voice note that says what to do with them; both are set on Manage → Webhooks, and each channel can set its own long gap in its settings. A burst never lives past 120 s. messages[] carries every message fully serialized (with media); combinedText joins the bodies and transcripts in order; files are assets. closedReason is instruction_received, idle_timeout (assets only — ask a human rather than invent a task), max_items (20) or max_duration. When instructionPending is true the voice note’s transcript will arrive on a later message.updated. AI agents should act on this event rather than on each message.created, which carries burstPending: true while a burst is open.
message.created{ message, conversationId }. Every message carries a media block: media.attachments[] with an absolute url, kind (image / audio / video / pdf / document / sticker / file), mimeType, size and, for voice notes, durationMs and voice: true; plus media.status (none / pending / ready / failed) and media.transcript.media.reason says why a voice note has no transcript (transcription_disabled — switch it on under Settings → AI). The raw attachments and metadata strings are unchanged for older integrations. Every message event also carries context — stable ids plus the organization, channel (non-secret config incl. status and verifiedName), contact (tags as an array, customFields as an object) and conversation — so an agent can decide whom it is talking to without a second lookup. WhatsApp messages also carry burstId, burstPending and recentWindow.
message.updated{ message, conversationId } — fires when a voice note’s transcript is ready or failed (media.status, media.transcript, and the transcript appended to body as [Voice message: …]), when a message note is resolved/unresolved, or when a call recording is attached.
message.delivery_updated{ message, conversationId, delivery: { status, provider, providerStatus, updatedAt, errorCode, error, retryRequested, retryCount }, context } — a provider reported on one of YOUR outbound messages. The ladder is queued → sending → sent → delivered → read → played, with failed terminal. Fired for WhatsApp (QR-connected and Cloud API) and Twilio SMS/WhatsApp. retryRequested means the recipient’s phone could not decrypt the message and asked for a resend (the “Waiting for this message” case), which BotInbox now answers. Chatty — subscribe to it separately from message.updated. A send tool’s delivery.status: "sent" only means the provider accepted the message; this event is the truth.
contact.createdThe contact object
contact.updatedThe contact object
contact.deleted{ id }
channel.createdThe channel object
channel.updatedThe channel object
Subscribe to * to receive all events — including any added in the future.

05Verify the signature

Always verify X-BotInbox-Signature before trusting a delivery — it proves the request really came from BotInbox. Compute HMAC-SHA256 of the raw request body using your webhook secret, hex-encode it, and compare it to the header with a timing-safe comparison.

You must hash the raw bytes. If you parse the JSON and re-serialize it, key order and spacing will differ and the signature will not match. Read the body before (or instead of) any JSON body parser.
Node.js / Express
const crypto = require("crypto")
const express = require("express")
const app = express()

const WEBHOOK_SECRET = process.env.BOTINBOX_WEBHOOK_SECRET

// IMPORTANT: capture the RAW body — not parsed JSON — for signature checking.
app.post(
  "/webhooks/botinbox",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body // Buffer of the raw request bytes
    const signature = req.header("X-BotInbox-Signature") || ""
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(raw)
      .digest("hex")

    const ok =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    if (!ok) return res.status(401).send("bad signature")

    const { event, data } = JSON.parse(raw.toString("utf8"))

    // Acknowledge first, then process.
    res.sendStatus(200)

    switch (event) {
      case "message.created":
        // data.message, data.conversationId
        break
      case "conversation.closed":
        // data is the conversation object
        break
      // ...handle the events you subscribed to
    }
  }
)

app.listen(3000)
Next.js (App Router) route handler
// app/api/webhooks/botinbox/route.ts
import crypto from "crypto"

export async function POST(req: Request) {
  const raw = await req.text() // raw body string — required for the signature
  const signature = req.headers.get("x-botinbox-signature") || ""
  const expected = crypto
    .createHmac("sha256", process.env.BOTINBOX_WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex")

  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return new Response("bad signature", { status: 401 })
  }

  const { event, data } = JSON.parse(raw)
  // Handle the event (ideally enqueue it and return immediately).
  return new Response("ok", { status: 200 })
}

06Retries & acknowledgement

Respond with any 2xx status as quickly as you can to acknowledge the delivery, and do heavy work asynchronously (after responding, or on a queue). Each attempt times out after 10 seconds.

If a delivery fails, BotInbox retries up to 3 attempts in total:

AttemptWhen
1Immediately (0s)
2~3 seconds after the first failure
3~10 seconds after the second failure

Retries happen on connection failures and on 408, 429, or 5xx responses. Any other 4xx is treated as a permanent rejection and is not retried — so only return 4xx if you truly want to drop the event.

A delivery your endpoint doesn't answer within 10 seconds is not retried: your endpoint has usually received it already and is still working, and a retry would make it do the work twice. It shows as failed in the delivery log, where you can resend it.

Make your handler idempotent. Because of retries, the same event may arrive more than once — design your processing so a duplicate delivery has no extra effect (de-duplicate on X-BotInbox-Delivery, or on the resource ID).

07Delivery log & resend

The dashboard records every delivery — the event name, response status, number of attempts, duration, and the full payload — so you can see exactly what was sent and how your endpoint responded.

Missed something during an outage or while debugging? You can resend any past delivery from /manage/webhooks with one click.

08Security

  • HTTPS is required — plain HTTP endpoints are rejected.
  • Webhook URLs pointing at private, internal, or loopback addresses are blocked.
  • Always verify the signature — anyone can POST JSON to a public URL; only BotInbox can sign it with your secret. See Verify the signature.

09Managing via API

You normally create and edit webhooks in the dashboard, but two API endpoints help with automation and debugging:

  • GET /api/v1/webhooks — list your configured webhooks.
  • POST /api/v1/webhooks/test — body { "webhookId": "..." }. Sends a test.ping event to that endpoint, signed with X-BotInbox-Signature exactly like a real delivery — perfect for verifying your signature check end-to-end.
cURL
# List your configured webhooks
curl https://botinbox.co.uk/api/v1/webhooks \
  -H "X-API-Key: bi_your_api_key"

# Send a signed test.ping delivery to one of them
curl -X POST https://botinbox.co.uk/api/v1/webhooks/test \
  -H "X-API-Key: bi_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"webhookId":"wh_abc123"}'

For authentication, endpoints, and the rest of the REST API, see the full API reference.

Ready to receive your first event?

Add an endpoint in the dashboard, send yourself a test.ping, and watch it arrive — signed and verified.