Webhooks
Webhooks call your server when something happens in ZebChat, such as a new conversation or message, so you can sync chats to your CRM or helpdesk without polling.
Add an endpoint
An endpoint is an https:// URL on the public internet, the list of events it wants, and a signing secret that ZebChat generates. The secret is shown once, when you create the endpoint or rotate it (POST …/webhooks/{id}/rotate-secret). An organization can have up to 20 endpoints.
curl -X POST https://api.zebchat.com/api/v1/organizations/org_…/webhooks \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/zebchat/webhooks",
"events": ["conversation.created", "message.created", "conversation.resolved"],
"description": "CRM sync"
}'
# → { "id": "whk_…", "enabled": true, …, "secret": "whsec_…" } (the secret is shown only here)POST …/webhooks/{id}/test sends a webhook.test event to that endpoint only, so you can check your handler.
Events
| Event | data | When |
|---|---|---|
conversation.created | { conversation } | A visitor started a chat (or left an offline message) |
conversation.assigned | { assignment } | Assigned, taken, transferred (reason "transfer") or unassigned (assignedUserId null) |
conversation.resolved | { conversation } | An agent resolved the conversation |
conversation.closed | { conversation } | An agent closed the conversation |
conversation.reopened | { conversation } | A resolved or closed conversation became open again |
conversation.rated | { rating } | The visitor rated the chat, or changed the rating (updated: true) |
message.created | { message } | Any message. Internal notes (isInternal: true) only if the endpoint opts in |
offline_message.created | { conversation, message } | A message left through the offline form, with the form answers |
visitor.identified | { visitor } | ZebChat.setUser() gave a name, email or phone, or a verified id |
conversation and message have the same shape as in the REST API (GET …/conversations/{id} and its messages); assignment and rating are the payloads of the realtime events of the same name. New fields may be added at any time, so ignore fields you don’t know.
What we send
A POST with a JSON body {id, type, createdAt, organizationId, data} and these headers:
ZebChat-Signature:t=<unix seconds>,v1=<hex>, wherev1is the HMAC-SHA256 of<t>.<raw body>keyed with the endpoint secret (the wholewhsec_…string).ZebChat-Event: the event type.ZebChat-Delivery: this attempt (whd_…). Retries and replays are new attempts with the same eventid: de-duplicate onid.
POST /zebchat/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: ZebChat-Webhooks/1.0
ZebChat-Event: message.created
ZebChat-Delivery: whd_0192…
ZebChat-Signature: t=1791158400,v1=5f8c0e…
{
"id": "evt_0192…",
"type": "message.created",
"createdAt": "2026-10-04T12:00:00.000Z",
"organizationId": "org_0192…",
"data": {
"message": {
"id": "msg_…", "conversationId": "conv_…", "seq": 3,
"senderType": "visitor", "sender": null,
"body": "Where is my order?", "isInternal": false,
"attachments": [], "clientMessageId": "…", "createdAt": "2026-10-04T12:00:00.000Z"
}
}
}Events can arrive out of order and, rarely, more than once. Use createdAt and the resource’s own timestamps, or fetch the current state with the API.
Verify the signature
Recompute the HMAC over the raw request body, compare it in constant time, and reject timestamps more than 5 minutes away from your clock (this stops replayed requests).
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.ZEBCHAT_WEBHOOK_SECRET; // whsec_…
const app = express();
// Verify against the raw bytes: parsing and re-serializing JSON changes them.
app.post('/zebchat/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('ZebChat-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
const body = req.body.toString('utf8');
const expected = createHmac('sha256', SECRET).update(`${t}.${body}`).digest();
const actual = Buffer.from(parts.v1 ?? '', 'hex');
const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
if (!fresh || actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(body);
// Reply fast; do the work in the background. De-duplicate by event.id.
res.sendStatus(204);
handle(event);
});PHP
<?php
$secret = getenv('ZEBCHAT_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // the raw body
$header = $_SERVER['HTTP_ZEBCHAT_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($body, true);
http_response_code(204);Python
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["ZEBCHAT_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
@app.post("/zebchat/webhooks")
def zebchat_webhook():
body = request.get_data() # raw bytes
parts = dict(p.split("=", 1) for p in request.headers.get("ZebChat-Signature", "").split(",") if "=" in p)
t = int(parts.get("t", "0"))
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = json.loads(body)
return "", 204Responses, retries and auto-disable
- Answer with any
2xxwithin 10 seconds. Anything else (an error status, a timeout, a redirect) is a failure. Redirects are never followed. - Failed deliveries are retried with exponential backoff: after about 1 minute, 5 minutes, 25 minutes, 2 hours and 10 hours (6 attempts in total).
- An endpoint that fails 20 times in a row, or keeps failing for 3 days, is turned off, and the organization’s owners and admins get an email. Turn it back on in Settings → Webhooks (or
PATCHwith{"enabled": true}) once it is fixed.
Delivery log and replay
Every attempt is logged with its status, response code, the first 2 KB of the response, timing and error: GET …/webhooks/{id}/deliveries (newest first, filter with ?status=failed). To send an event again, for example after an outage, use POST …/deliveries/{deliveryId}/replay.
Network rules
ZebChat only delivers to public addresses. URLs with credentials, IP addresses in private, loopback, link-local or other reserved ranges, and internal host names are refused when you save them. Host names are resolved again before every delivery, and the connection goes to the address that was checked.