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:
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.
{ "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…:
-
tis when Rabbitblaze signed the request, in Unix seconds. -
v1is the hex HMAC-SHA256 of the string{t}.{raw body}, keyed with your endpoint’s signing secret — the wholewhsec_…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
idand skip events you’ve already processed. - Don’t rely on order. Deliveries run in parallel and retries arrive late. Use
createdAtand the objects’updatedAtto 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
pingright 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.