Webhooks
Instead of your server asking Botzo every minute "anything new?", Botzo sends your server a POST request the moment something happens: a customer message, a message status change, a request for a person, or a reply in a Mochi conversation.
Setup
- In Developer tools → Webhooks, enter your server's URL (must start with
https://), pick the events, then Add webhook. - The signing secret (starts with
whsec_) is shown once. Store it on your server (BOTZO_WEBHOOK_SECRET). - Press Send test event: you get a
webhook.testevent and see the result under "Recent deliveries".
Up to 3 URLs per organization, each with its own events and secret. You can pause any URL or rotate its secret.
The test receiver
No server yet? Press Use the test receiver: a private URL for your organization inside Botzo that keeps the last 20 requests it receives, shows their content, and tells you whether the signature is valid. Add it as a webhook and try (send a message from your phone, for example) to see every real event before writing a line of code.
The request
Every notification is a POST with a JSON body and these headers:
| Header | Example | Description |
|---|---|---|
Content-Type |
application/json |
|
User-Agent |
Botzo-Webhooks/1.0 |
|
X-Botzo-Event |
message.received |
Event type |
X-Botzo-Delivery |
evt_QM7BsjgZLhxD1uKuq8vpyuOC |
Event id, the same on every retry |
X-Botzo-Timestamp |
1792693386 |
Send time (Unix seconds) |
X-Botzo-Signature |
sha256=60a52fe7… |
Signature (below) |
The body always has this envelope:
{
"id": "evt_QM7BsjgZLhxD1uKuq8vpyuOC",
"type": "message.received",
"created_at": "2026-10-22T18:23:11+00:00",
"data": { "...": "depends on the event" }
}
Verifying the signature
Anyone who knows your URL can send it a fake request. The signature proves the request came from Botzo and was not changed:
signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + raw_body)
- Take the raw body exactly as received (before parsing JSON).
- Compute HMAC-SHA256 with the signing secret over
X-Botzo-Timestamp+.+ body. - Compare with
X-Botzo-Signatureusing a constant-time comparison. - Reject requests whose
X-Botzo-Timestampis older than 5 minutes (protects against replays).
// Node.js (Express): note express.raw to get the raw body
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/botzo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Botzo-Timestamp') ?? '';
const signature = req.get('X-Botzo-Signature') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.BOTZO_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body);
res.sendStatus(200); // answer right away
handleEvent(event); // then process
});
// PHP / Laravel
Route::post('/botzo/webhook', function (Illuminate\Http\Request $request) {
$timestamp = (string) $request->header('X-Botzo-Timestamp');
$body = $request->getContent(); // raw body
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, env('BOTZO_WEBHOOK_SECRET'));
if (! hash_equals($expected, (string) $request->header('X-Botzo-Signature'))
|| abs(time() - (int) $timestamp) > 300) {
abort(401);
}
$event = json_decode($body, true);
dispatch(new HandleBotzoEvent($event)); // process in the background
return response()->noContent();
});
# Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/botzo/webhook")
def botzo_webhook():
timestamp = request.headers.get("X-Botzo-Timestamp", "")
body = request.get_data(as_text=True) # raw body
expected = "sha256=" + hmac.new(os.environ["BOTZO_WEBHOOK_SECRET"].encode(),
f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Botzo-Signature", "")) \
or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = request.get_json()
handle_event(event)
return "", 200
The most common mistake: signing the JSON after parsing and re-serializing it. Spacing or key order can change, so the signature won't match. Always use the raw body.
Events
| Event | When |
|---|---|
message.received |
A customer sent a WhatsApp message (text, image, audio, file, button tap) |
message.status |
A message you sent changed status: sent → delivered → read, or failed |
conversation.handoff |
The conversation moved to a person (customer asked, Mochi handed over, or an automation) |
conversation.reply |
Mochi or an agent replied in a website widget or Mochi API conversation |
webhook.test |
The "Send test event" button |
message.received
{
"id": "evt_QM7BsjgZLhxD1uKuq8vpyuOC",
"type": "message.received",
"created_at": "2026-10-22T18:23:11+00:00",
"data": {
"message_id": "wamid.INBOUND1",
"channel": "whatsapp",
"from": "+966501234567",
"contact": { "name": "Sara Ali", "phone": "+966501234567" },
"type": "text",
"text": "When will my order arrive?",
"button": null,
"media": null,
"timestamp": "2026-10-22T18:23:11+00:00"
}
}
data.type |
What it carries |
|---|---|
text |
text: the message |
interactive or button |
button.id and button.title: the button the customer tapped (the id you set) |
image, video, audio, document, sticker |
media.type, media.mime_type, media.caption, media.filename, and media.voice: true for recorded voice notes |
Button tap:
"data": {
"message_id": "wamid.INBOUND3",
"from": "+966501234567",
"type": "interactive",
"text": null,
"button": { "id": "track_order", "title": "Track order" },
"media": null
}
Image:
"data": {
"message_id": "wamid.INBOUND2",
"type": "image",
"media": { "type": "image", "mime_type": "image/jpeg", "caption": "Product photo", "filename": null, "voice": false }
}
The file itself is not sent in the event. Your team sees it in the inbox.
message.status
{
"id": "evt_RuQMhTerKNbXdPBmpqRgyGNs",
"type": "message.status",
"created_at": "2026-10-22T18:23:11+00:00",
"data": {
"message_id": "wamid.HBgMOTY2NTAxMjM0NTY3FQIAERgSMEE2",
"channel": "whatsapp",
"to": "+966501234567",
"status": "read",
"error": null,
"timestamp": "2026-10-22T18:23:11+00:00"
}
}
On failure:
"data": {
"message_id": "wamid.TEMPLATE1",
"to": "+966501234567",
"status": "failed",
"error": { "code": 131026, "title": "Message undeliverable", "message": "Message undeliverable" }
}
message_id is the one you got when sending, so you can match the event to the message in your system. The event is only sent when the status moves forward (no delivered after read).
conversation.handoff
{
"id": "evt_30gW1n7TG1SoQkRadkKZyjBX",
"type": "conversation.handoff",
"created_at": "2026-10-22T18:23:11+00:00",
"data": {
"channel": "whatsapp",
"contact": { "name": "Sara Ali", "phone": "+966501234567" },
"reason": "human_handoff",
"timestamp": "2026-10-22T18:23:11+00:00"
}
}
reason |
Cause |
|---|---|
human_handoff |
The customer asked for a person ("I want to talk to someone") |
ai_knowledge_gap |
Mochi didn't find an answer in your knowledge and handed over |
ai_unavailable |
The AI could not answer (for example no credit left) and handed over |
voice_call_handoff |
A person was requested during a voice call with Mochi |
flow_assignment |
An "assign to agent" step in an automation |
channel is whatsapp or web (widget and Mochi API); contact.phone is null for web visitors.
conversation.reply
{
"id": "evt_xTXJcJDSuckQj3Btd0MY6nSQ",
"type": "conversation.reply",
"created_at": "2026-10-22T18:23:11+00:00",
"data": {
"conversation_id": "7f6c5f69-f8a3-4fc3-89f7-50cdb64f69b6",
"source": "api",
"user_id": "customer-42",
"message_id": 127,
"from": "agent",
"text": "Salem from the sales team here, how can I help?",
"timestamp": "2026-10-22T18:23:11+00:00"
}
}
| Field | Description |
|---|---|
source |
api (Mochi API conversation) or widget (website widget) |
user_id |
The user.id your server sent (secret key), otherwise null |
from |
assistant (Mochi) or agent (team member) |
Use it to notify the user in your app when an agent answers, without polling the API.
webhook.test
{
"id": "evt_gh6R8NqOyRV786sZqdhjWwQr",
"type": "webhook.test",
"created_at": "2026-10-22T18:23:11+00:00",
"data": { "message": "Test event from Botzo. If you can read this, your webhook works.", "timestamp": "2026-10-22T18:23:11+00:00" }
}
Responding and retries
- Respond with any 2xx within 5 seconds. Anything else, a timeout, or a redirect (3xx) counts as a failure.
- Respond first, then process: store the event, return
200at once, and do heavy work in the background. - On failure we retry with the same
X-Botzo-Deliveryafter 1 minute, 5 minutes, 30 minutes, 2 hours (up to 5 attempts). Retries run every few minutes, so they may land a little later. - After 20 failures in a row the URL is switched off and shows "Stopped after repeated failures". Fix your server, then press "Turn on".
- The last 50 deliveries (result, attempts, time, error) are in Developer tools → Recent deliveries; we keep the log for 14 days.
Best practices
- Ignore duplicates: the same event may arrive more than once (a retry after a timeout). Store each processed
idand skip repeats. - Don't rely on order:
readmay rarely arrive beforedelivered. Keep the highest status you've seen. - Always verify the signature before acting.
- The URL must be
httpsand publicly reachable. We never send to internal addresses (localhost,10.x,192.168.x). - Rotating the secret in the dashboard stops the old one at once, so update your server right away.