Webhooks
Webhooks push events from Mulabbe to your server as signed HTTP POST requests, so you do not need to poll the API. Typical uses: mirror new orders into an ERP, alert your team when a conversation is escalated, or sync opt-outs into your marketing lists.
Setting up an endpoint
Endpoints are managed in the dashboard under Settings → Developers → Webhooks (owners and admins). A workspace can have up to 10 endpoints. For each endpoint you choose:
- URL — must be
https://and reachable on a public address. Redirects are not followed. - Description (optional).
- Events — the list of event types this endpoint subscribes to (see the catalogue below).
When you create an endpoint, its signing secret (whsec_...) is shown once. Store it on your server; you need it to verify deliveries. You can rotate the secret later — after a rotation, deliveries are signed with both the new and the old secret for 24 hours so you can switch without dropping events.
From the same screen you can send a test event (webhook.test) to any endpoint, browse its delivery log (status, response code, timing) and redeliver a failed delivery.
Event catalogue
| Event | Sent when | data fields |
|---|---|---|
message.received | A customer message arrives on any channel. | conversationId, messageId, channelType, senderType, sentVia |
message.sent | An outbound message is sent (by the assistant or a human agent). | conversationId, messageId, channelType, senderType, sentVia |
conversation.state_changed | A conversation changes state. | conversationId, from, to, actorType |
conversation.escalated | A conversation is handed to a human. | conversationId, escalationId, reason, priority |
order.created | An order is placed. | orderId, orderNumber, conversationId, contactId, status, paymentMethod, totalMinor, currency, placedBy, source |
order.updated | An order's status changes. | orderId, conversationId, from, to |
lead.created | A lead is captured in a conversation (announced once per lead). | leadId, conversationId, complete, temperature |
contact.opted_in | A contact opts in to marketing messages. | contactId, channelType, purpose, source |
contact.opted_out | A contact opts out. | contactId, channelType, purpose, source |
channel.health_changed | A connected channel becomes unhealthy or recovers. | channelAccountId, channelType, from, to |
usage.threshold_reached | The workspace's AI credits for the period reach 80% used, and again at 100%. | resource ("ai_credits"), level, threshold (80 or 100), month |
webhook.test | You press "Send test event" in the dashboard. Sent regardless of subscriptions. | message |
Event payloads carry identifiers and key facts, not full records: fetch details through the REST API when you need more.
The delivery
Each delivery is a POST with a JSON body:
{
"id": "6d1f9d0e-2a74-4b7f-9b6e-1f1a2b3c4d5e",
"type": "order.created",
"apiVersion": "2026-10-01",
"createdAt": "2026-10-16T09:30:00.000Z",
"data": {
"orderId": "0b64f1f0-...",
"orderNumber": 1042,
"status": "pending_confirmation",
"totalMinor": 24900,
"currency": "SAR"
}
}
ididentifies the event. If an event is redelivered or retried,idstays the same — use it to deduplicate.apiVersionis the version of the payload shape (currently2026-10-01).
Headers on every delivery:
| Header | Meaning |
|---|---|
x-webhook-id | The event id (same as id in the body). |
x-webhook-event | The event type, for example order.created. |
x-webhook-delivery | The id of this specific delivery attempt chain (new on redelivery). |
x-webhook-signature | The signature to verify — see below. |
user-agent | Mulabbe-Webhooks/1.0 |
Your endpoint must answer with any 2xx status within 10 seconds. Anything else — including a 3xx redirect — counts as a failure. Answer quickly and do the real work asynchronously.
Retries
Failed deliveries are retried with growing delays: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the previous attempt (about 47 hours in total). After the last retry the delivery is marked failed; you can redeliver it manually from the delivery log.
If every delivery to an endpoint keeps failing for 3 days, the endpoint is automatically disabled and the workspace owners get an email. Fix your receiver, enable the endpoint again in the dashboard, and redeliver what you missed from the delivery log.
Verifying signatures
Every delivery is signed with your endpoint's secret. Always verify the signature before trusting a delivery — anyone who discovers your URL can send it fake requests.
The x-webhook-signature header looks like:
t=1760606400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the Unix timestamp (in seconds) of when the delivery was signed.v1isHMAC-SHA256(secret, "<t>.<raw body>")in lowercase hex.- During a secret rotation the header carries two
v1values (one per secret). Accept the delivery if any of them matches.
To verify:
- Read the raw request body exactly as received. Do not parse and re-serialize the JSON first — one changed byte breaks the signature.
- Parse
tand allv1values from the header. - Reject if
tdiffers from your current time by more than 300 seconds (this blocks replay attacks). - Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare it with eachv1using a constant-time comparison.
Node.js
const crypto = require('node:crypto');
function verifyMulabbeSignature(secret, header, rawBody, toleranceSec = 300) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = Number(value);
else if (key === 'v1') signatures.push(value.toLowerCase());
}
if (!t || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
return signatures.some((sig) => {
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sig, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
// Express example: keep the raw body for verification.
const express = require('express');
const app = express();
app.post('/mulabbe-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyMulabbeSignature(process.env.MULABBE_WEBHOOK_SECRET, req.header('x-webhook-signature'), req.body);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge fast; process asynchronously.
res.status(200).end();
handleEvent(event);
});
Python
import hashlib
import hmac
import time
def verify_mulabbe_signature(secret: str, header: str, raw_body: bytes, tolerance_sec: int = 300) -> bool:
if not header:
return False
t = None
signatures = []
for part in header.split(","):
key, _, value = part.partition("=")
key, value = key.strip(), value.strip()
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1":
signatures.append(value.lower())
if t is None or not signatures:
return False
if abs(int(time.time()) - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
# Flask example: request.get_data() returns the raw body.
from flask import Flask, request
app = Flask(__name__)
@app.post("/mulabbe-webhook")
def mulabbe_webhook():
ok = verify_mulabbe_signature(
MULABBE_WEBHOOK_SECRET,
request.headers.get("x-webhook-signature", ""),
request.get_data(),
)
if not ok:
return "", 401
event = request.get_json()
# Acknowledge fast; process asynchronously.
return "", 200
Best practices
- Deduplicate by event
id. Retries and manual redelivery can bring the same event more than once. - Return 2xx quickly, then process in a queue or background job. Slow handlers hit the 10-second timeout and cause retries.
- Do not rely on ordering. Deliveries can arrive out of order, especially around retries. Use the
createdAttimestamp and the ids indatato reconcile. - Treat webhooks as a trigger, not a source of truth. When in doubt, read the current state from the REST API.