Reference
Webhooks
Receive every ticket, form submission and chat escalation on your own platform, verify the signature, and handle retries.
Setup
Open Businesses > your website > Settings > Delivery and select the Engage connection:
- Enter a public HTTPS endpoint URL.
- Enter a signing secret you generate — 32 random bytes, base64 or hex. Store the same value on your receiver.
- Save the connection, exercise the selected app and verify your receiver's signed event and
2xxacknowledgement. Administrative integrations can also use the documented webhook test endpoint.
Events target the configured endpoint; a stored ticket does not prove webhook delivery. Route on the X-Buckhy-Engage-Event header or the event property.
Events
| Event | App | When | data |
|---|---|---|---|
ticket.created |
Support Desk | A support ticket is stored | The ticket |
form.submitted |
Managed Forms | A managed form submission is stored | The ticket created for the submission |
chat.escalated |
AI Chat | A message mentions billing, refunds, legal or account actions and is steered to support | { conversationId, visitor, page, pageTitle, message, answer, status, occurredAt } |
webhook.test |
— | You click Send test event | A sample ticket-shaped object |
Ticket data fields: id, ticketId, siteId, source (support or form), form, contact { name, email, phone }, category, subject, status, messages[] { sender, body, sentAt }, fields, page, conversationId, notifiedAt, webhookDeliveredAt, createdAt, updatedAt.
Delivery
Each delivery is a POST with a JSON body:
POST /engage-webhook HTTP/1.1
Content-Type: application/json
X-Buckhy-Engage-Event: form.submitted
X-Buckhy-Engage-Signature: t=1756852800,v1=6d2e0c…e91f
{
"event": "form.submitted",
"siteId": "6a9…",
"occurredAt": "2026-09-02T21:20:00.000Z",
"data": { "ticketId": "ENG-F-5C213AA2", "form": "contact", "fields": { … }, … }
}
| Behaviour | Detail |
|---|---|
| Timeout | 8 seconds per attempt |
| Retries | Up to 3 attempts on network errors and 5xx, with 400 ms then 800 ms between attempts |
4xx |
Treated as rejected; not retried |
| Ordering | Not guaranteed. Use occurredAt and data.ticketId |
| Duplicates | Possible if your endpoint times out after storing. Make data.ticketId (or conversationId) unique on your side |
Respond 2xx as soon as you have persisted the event; do heavy work afterwards.
Verify the signature
The signature header is t=<unix seconds>,v1=<hex> where v1 = HMAC_SHA256(secret, t + "." + rawBody). Verify over the raw request body, compare in constant time, and reject timestamps older than a few minutes.
Node / Express
const crypto = require("crypto");
app.post("/engage-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = String(req.headers["x-buckhy-engage-signature"] || "");
const t = header.match(/t=(\d+)/)?.[1] ?? "";
const v1 = header.match(/v1=([0-9a-f]+)/)?.[1] ?? "";
const expected = crypto.createHmac("sha256", process.env.ENGAGE_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString("utf8")}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const valid = v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !valid) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body.toString("utf8"));
// upsert by data.ticketId (or data.conversationId for chat.escalated)
res.sendStatus(200);
});
Next.js route handler
import crypto from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text();
const header = request.headers.get("x-buckhy-engage-signature") ?? "";
const t = header.match(/t=(\d+)/)?.[1] ?? "";
const v1 = header.match(/v1=([0-9a-f]+)/)?.[1] ?? "";
const expected = crypto.createHmac("sha256", process.env.ENGAGE_WEBHOOK_SECRET!)
.update(`${t}.${raw}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || v1.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return Response.json({ error: "bad signature" }, { status: 401 });
}
const { event, data } = JSON.parse(raw);
return Response.json({ ok: true });
}
.NET minimal API
app.MapPost("/engage-webhook", async (HttpRequest http, IConfiguration config) =>
{
var body = await new StreamReader(http.Body).ReadToEndAsync();
var header = http.Headers["X-Buckhy-Engage-Signature"].ToString();
var t = header.Split(',').FirstOrDefault(p => p.StartsWith("t="))?[2..] ?? "";
var v1 = header.Split(',').FirstOrDefault(p => p.StartsWith("v1="))?[3..] ?? "";
if (!long.TryParse(t, out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300)
return Results.Unauthorized();
using var hmac = new System.Security.Cryptography.HMACSHA256(
System.Text.Encoding.UTF8.GetBytes(config["Engage:WebhookSecret"]!));
var expected = Convert.ToHexString(
hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes($"{t}.{body}"))).ToLowerInvariant();
var ok = System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(v1), System.Text.Encoding.UTF8.GetBytes(expected));
return ok ? Results.Ok() : Results.Unauthorized();
});
Parse the JSON only after the signature verifies, and always sign over the raw bytes — re-serialising the body changes whitespace and breaks the comparison.
