API reference · Guides
Webhooks
We POST the events you choose (new messages, and orders being placed, paid, cancelled or refunded) to a URL you choose, signed with a secret only you and we know. Use it to mirror conversations into a CRM, push orders into your store or kitchen, or keep an audit copy.
Set it up
- In the dashboard, open Integrations, Webhooks, enter an HTTPS URL and tick the events you want. One endpoint per workspace.
- Copy the signing secret. It is shown once, when the webhook is first saved or when you rotate it.
- Answer every delivery with a 2xx status within 10 seconds.
URLs that resolve to private or internal addresses are refused, and a redirect is treated as a failed delivery rather than followed.
Events
| Event | When |
|---|---|
message.created | A message was added to any conversation, on any channel: the customer's, the agent's reply and a team member's reply. Internal notes are never sent. The default subscription. |
order.placed | An order was placed by the agent, on a call or in chat. Stock for it is held from this moment. |
order.paid | The customer paid the order's payment link, or your store reported it paid. |
order.cancelled | The order was cancelled. order.expired is true when it was an unpaid order whose payment window closed (30 minutes by default) and its stock was put back on sale. |
order.refunded | The order was refunded. |
{
"event": "message.created",
"message": {
"id": "f2a3b4c5-d6e7-4f8a-9b0c-1d2e3f4a5b6c",
"conversation_id": "6f1c2a90-3b7e-4d55-9a1e-2c8f0b7d4e11",
"role": "assistant",
"content": "Ada! Nasi lemak ayam goreng RM12.90. Nak order berapa?",
"channel": "whatsapp",
"created_at": "2026-09-27T03:14:07.512Z"
}
}Read the full conversation with GET /api/v1/conversations/{id}/messages.
{
"event": "order.placed",
"order": {
"id": "0b8f3c2e-7a41-4d9e-b6c5-3e2f1a0d9c87",
"bot_id": "9d7e6f5a-4b3c-4d2e-8f1a-0b9c8d7e6f5a",
"order_number": "ORD-20260927-4K7QZ",
"status": "pending_payment",
"channel": "whatsapp",
"items": [
{
"name": "Nasi Lemak (Ayam)",
"quantity": 2,
"unitPriceSen": 1290,
"specialInstructions": "no sambal",
"productId": "5c4b3a29-1807-4f6e-9d5c-4b3a29180716",
"variantId": "7e6d5c4b-3a29-4180-8f6e-5d4c3b2a1908"
}
],
"subtotal": 25.8,
"total": 25.8,
"currency": "MYR",
"conversation_id": "6f1c2a90-3b7e-4d55-9a1e-2c8f0b7d4e11",
"customer": {
"name": "Siti",
"phone": "+60123456789"
},
"payment_url": null,
"payment_expires_at": "2026-09-27T03:44:07.512Z",
"expired": false,
"paid_at": null,
"external_id": null,
"created_at": "2026-09-27T03:14:07.512Z"
}
}order.paid, order.cancelled and order.refunded carry the same order object as it stands after the change. Acknowledge an order in your store with POST /api/v1/orders/{id}/status.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-IceBot-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your signing secret. |
X-IceBot-Event | The event name, e.g. message.created or order.placed. |
X-IceBot-Delivery | The delivery's id. The same on every retry of it, so you can de-duplicate on it. |
Verify the signature
Compute the HMAC over the exact bytes you received and compare in constant time. Refuse anything that does not match.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Verify against the RAW body: re-serialising parsed JSON changes the bytes.
app.post('/icebot', express.raw({ type: 'application/json' }), (req, res) => {
const expected =
'sha256=' + crypto.createHmac('sha256', process.env.ICEBOT_WEBHOOK_SECRET).update(req.body).digest('hex');
const got = req.get('X-IceBot-Signature') ?? '';
const ok =
got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// Deliveries can repeat after a timeout: de-duplicate on the delivery id.
// req.get('X-IceBot-Delivery')
res.status(200).end(); // answer fast, then do the work
handle(event);
});Delivery and retries
- Deliveries are sent by a worker that runs every 5 minutes, so expect up to that delay. Webhooks are for syncing, not for replying in real time.
- Any non-2xx status, a redirect, or no answer within 10 seconds counts as a failure. We retry after 1 min, 5 min, 30 min, 2 h, 6 h.
- After 6 failed attempts the delivery is marked failed and not retried. Use the conversations API to backfill anything you missed.
- Order is not guaranteed and a delivery can arrive more than once. De-duplicate on the
X-IceBot-Deliveryheader.