Skip to content
Documentation menu

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.

Shell
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

EventdataWhen
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>, where v1 is the HMAC-SHA256 of <t>.<raw body> keyed with the endpoint secret (the whole whsec_… string).
  • ZebChat-Event: the event type.
  • ZebChat-Delivery: this attempt (whd_…). Retries and replays are new attempts with the same event id: de-duplicate on id.
HTTP
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

JavaScript
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
<?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

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 "", 204

Responses, retries and auto-disable

  • Answer with any 2xx within 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 PATCH with {"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.