Webhooks

Receiving webhooks

Rabbitblaze sends a signed JSON POST to your endpoint whenever a subscribed event happens — conversations, tickets, people and articles.

Add an endpoint

In Settings → Developers → Webhooks, choose Add endpoint, enter the URL that should receive events and pick the topics you need. Rabbitblaze shows the endpoint’s signing secret (whsec_…) — store it with your receiver; you can reveal or rotate it later.

  • The URL must be public HTTPS, on the default port or a port above 1023.
  • Private, loopback and internal addresses are refused when you save the endpoint and again on every delivery, so a DNS change can’t point deliveries into a private network.
  • Redirects aren’t followed: use the final URL.
  • A workspace can have up to 20 endpoints. Webhooks are included in every paid plan.

The request

Each delivery is an HTTP POST with a JSON body — the envelope — and these headers:

Request
POST /webhooks/rabbitblaze HTTP/1.1Host: example.comContent-Type: application/json; charset=utf-8User-Agent: Rabbitblaze-Webhooks/1.0 (+https://rabbitblaze.com/developers/webhooks)X-Rabbitblaze-Signature: t=1791826000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdX-Rabbitblaze-Topic: conversation.createdX-Rabbitblaze-Delivery: 0199c520-7f2a-7a11-9b22-3c44d5e6f708X-Rabbitblaze-Event-Id: 0199c520-7f2a-7e3b-8c4d-5e6f7a8b9c0dX-Rabbitblaze-Attempt: 1
Header Meaning
X-Rabbitblaze-Signature Timestamp and HMAC signature — verify it.
X-Rabbitblaze-Topic The topic, same as type in the body.
X-Rabbitblaze-Event-Id The event id: the same for every endpoint and every retry.
X-Rabbitblaze-Delivery This endpoint’s delivery of the event (shown in the delivery log).
X-Rabbitblaze-Attempt 1 for the first attempt, then 2, 3… for retries and redeliveries.

The envelope has the event id, its type (the topic), your workspaceId, the apiVersion of the payload, when the event happened (createdAt) and the data, which carries the affected objects as they were when the event was processed — the same objects as the REST API, so you rarely need a follow-up request.

Body · conversation.created
{  "id": "0199c520-7f2a-7e3b-8c4d-5e6f7a8b9c0d",  "type": "conversation.created",  "workspaceId": "0199a8f2-1c3d-7e4f-8a5b-6c7d8e9f0a1b",  "apiVersion": "2026-10-01",  "createdAt": "2026-10-02T14:05:31.000Z",  "data": {    "conversation": {      "object": "conversation",      "id": "0199c520-7e8f-7a9b-8c0d-1e2f3a4b5c6d",      "number": 1042,      "state": "open",      "channel": "messenger",      "subject": null,      "title": "Invoice shows the wrong VAT number",      "isPriority": false,      "contact": {        "id": "0199c512-6a7b-7c8d-9e0f-1a2b3c4d5e6f",        "name": "Lena Fischer",        "email": "[email protected]",        "role": "user"      },      "companyId": "0199c514-2b3c-7d4e-9f5a-6b7c8d9e0f1a",      "assignee": {        "id": "0199a8f2-3e4f-7a5b-8c6d-7e8f9a0b1c2d",        "name": "Jonas Weber"      },      "team": {        "id": "0199a8f2-4f5a-7b6c-9d7e-8f9a0b1c2d3e",        "name": "Billing"      },      "tags": [        {          "id": "0199a8f3-5d6e-7f7a-8b8c-9d0e1f2a3b4c",          "name": "VIP"        }      ],      "customAttributes": {        "invoice_number": "INV-4412"      },      "waitingSince": null,      "snoozedUntil": null,      "closedAt": null,      "lastMessageAt": "2026-10-02T14:11:08.000Z",      "firstContactMessageAt": "2026-10-02T14:05:31.000Z",      "firstTeammateReplyAt": "2026-10-02T14:11:08.000Z",      "aiAgentState": "handed_off",      "rating": null,      "sla": {        "status": "met",        "dueAt": null      },      "ticket": null,      "messageCount": 4,      "createdAt": "2026-10-02T14:05:31.000Z",      "updatedAt": "2026-10-02T14:11:08.000Z"    }  }}

Verify signatures

Anyone can send a POST to your URL, so check that each request comes from Rabbitblaze before acting on it. The X-Rabbitblaze-Signature header looks like t=1791826000,v1=5257a8…:

  1. t is when Rabbitblaze signed the request, in Unix seconds.
  2. v1 is the hex HMAC-SHA256 of the string {t}.{raw body}, keyed with your endpoint’s signing secret — the whole whsec_… string.

Recompute the signature over the raw request body (exactly the bytes received — parsing and re-serializing the JSON changes them), compare it in constant time, and reject timestamps more than 5 minutes away from your clock to stop replays.

import express from "express"import { createHmac, timingSafeEqual } from "node:crypto" const SECRET = process.env.RABBITBLAZE_WEBHOOK_SECRET // whsec_…const TOLERANCE_SECONDS = 300 function isValidSignature(rawBody, header) {  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")))  const timestamp = Number(parts.t)  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false  const expected = createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex")  const received = parts.v1 ?? ""  return received.length === expected.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))} const app = express() // Keep the raw body: the signature covers the exact bytes Rabbitblaze sent.app.post("/webhooks/rabbitblaze", express.raw({ type: "application/json" }), (req, res) => {  const rawBody = req.body.toString("utf8")  if (!isValidSignature(rawBody, req.get("X-Rabbitblaze-Signature") ?? "")) {    return res.status(400).send("Invalid signature")  }  const event = JSON.parse(rawBody)  res.sendStatus(200) // answer within 10 seconds, then do the work  handleEvent(event) // skip event.id values you’ve already handled})
import hashlibimport hmacimport jsonimport osimport time from flask import Flask, abort, request SECRET = os.environ["RABBITBLAZE_WEBHOOK_SECRET"]  # whsec_…TOLERANCE_SECONDS = 300 app = Flask(__name__)  def is_valid_signature(raw_body: bytes, header: str) -> bool:    parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)    try:        timestamp = int(parts.get("t", ""))    except ValueError:        return False    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:        return False    signed = f"{timestamp}.".encode() + raw_body    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()    return hmac.compare_digest(expected, parts.get("v1", ""))  @app.post("/webhooks/rabbitblaze")def rabbitblaze_webhook():    raw_body = request.get_data()  # the exact bytes Rabbitblaze sent    if not is_valid_signature(raw_body, request.headers.get("X-Rabbitblaze-Signature", "")):        abort(400)    event = json.loads(raw_body)    handle_event(event)  # skip event["id"] values you've already handled    return "", 200
<?php$secret = getenv('RABBITBLAZE_WEBHOOK_SECRET'); // whsec_…$tolerance = 300; $rawBody = file_get_contents('php://input'); // the exact bytes Rabbitblaze sent$header = $_SERVER['HTTP_X_RABBITBLAZE_SIGNATURE'] ?? ''; $parts = [];foreach (explode(',', $header) as $part) {    [$key, $value] = array_pad(explode('=', $part, 2), 2, '');    $parts[$key] = $value;}$timestamp = (int) ($parts['t'] ?? 0);$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); if (abs(time() - $timestamp) > $tolerance || !hash_equals($expected, $parts['v1'] ?? '')) {    http_response_code(400);    exit('Invalid signature');} $event = json_decode($rawBody, true);handle_event($event); // skip $event['id'] values you've already handledhttp_response_code(200);

Respond quickly

Answer with any 2xx status within 10 seconds. Everything else counts as a failure: other statuses (redirects included), timeouts, TLS and connection errors. Acknowledge first and do the work in a background job. The first 4 KB of your response body is kept in the delivery log to help debugging.

Retries

Failed deliveries are retried with exponential backoff (±10 % jitter) for about a day — 9 attempts in all. After the last one the delivery is marked failed; you can still redeliver it from the log.

Attempt Wait before it Time since the event
1 — right away
2 30 s 30 s
3 2 min ≈ 3 min
4 10 min ≈ 13 min
5 30 min ≈ 43 min
6 1 h ≈ 1.7 h
7 3 h ≈ 4.7 h
8 6 h ≈ 11 h
9 12 h ≈ 23 h

Duplicates and ordering

  • Expect duplicates. A retry after a timeout or a manual redelivery sends the same event again. Store the envelope’s id and skip events you’ve already processed.
  • Don’t rely on order. Deliveries run in parallel and retries arrive late. Use createdAt and the objects’ updatedAt to keep the newest state, or fetch the current state from the API when order matters.

Disabled endpoints

If an endpoint hasn’t accepted a single delivery for 20 hours by the time a delivery runs out of retries, Rabbitblaze turns it off and emails the workspace’s owners and admins. Fix the receiver, turn the endpoint back on in the dashboard — that clears its failure count — and redeliver what it missed. You can also pause an endpoint yourself; events that happen while it’s paused aren’t sent.

Testing and the delivery log

  • Send test event on the endpoint’s page delivers a ping right away (not retried), so you can check your receiver and its signature check.
  • The delivery log keeps every delivery for 30 days: the exact request (headers and body) and each attempt with its status, duration, response body or error.
  • Redeliver sends any delivery again, signed with a fresh timestamp.