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

EventSent whendata fields
message.receivedA customer message arrives on any channel.conversationId, messageId, channelType, senderType, sentVia
message.sentAn outbound message is sent (by the assistant or a human agent).conversationId, messageId, channelType, senderType, sentVia
conversation.state_changedA conversation changes state.conversationId, from, to, actorType
conversation.escalatedA conversation is handed to a human.conversationId, escalationId, reason, priority
order.createdAn order is placed.orderId, orderNumber, conversationId, contactId, status, paymentMethod, totalMinor, currency, placedBy, source
order.updatedAn order's status changes.orderId, conversationId, from, to
lead.createdA lead is captured in a conversation (announced once per lead).leadId, conversationId, complete, temperature
contact.opted_inA contact opts in to marketing messages.contactId, channelType, purpose, source
contact.opted_outA contact opts out.contactId, channelType, purpose, source
channel.health_changedA connected channel becomes unhealthy or recovers.channelAccountId, channelType, from, to
usage.threshold_reachedThe workspace's AI credits for the period reach 80% used, and again at 100%.resource ("ai_credits"), level, threshold (80 or 100), month
webhook.testYou 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"
  }
}
  • id identifies the event. If an event is redelivered or retried, id stays the same — use it to deduplicate.
  • apiVersion is the version of the payload shape (currently 2026-10-01).

Headers on every delivery:

HeaderMeaning
x-webhook-idThe event id (same as id in the body).
x-webhook-eventThe event type, for example order.created.
x-webhook-deliveryThe id of this specific delivery attempt chain (new on redelivery).
x-webhook-signatureThe signature to verify — see below.
user-agentMulabbe-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
  • t is the Unix timestamp (in seconds) of when the delivery was signed.
  • v1 is HMAC-SHA256(secret, "<t>.<raw body>") in lowercase hex.
  • During a secret rotation the header carries two v1 values (one per secret). Accept the delivery if any of them matches.

To verify:

  1. Read the raw request body exactly as received. Do not parse and re-serialize the JSON first — one changed byte breaks the signature.
  2. Parse t and all v1 values from the header.
  3. Reject if t differs from your current time by more than 300 seconds (this blocks replay attacks).
  4. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare it with each v1 using 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 createdAt timestamp and the ids in data to reconcile.
  • Treat webhooks as a trigger, not a source of truth. When in doubt, read the current state from the REST API.