BuckhyDeveloper Platform

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:

  1. Enter a public HTTPS endpoint URL.
  2. Enter a signing secret you generate — 32 random bytes, base64 or hex. Store the same value on your receiver.
  3. Save the connection, exercise the selected app and verify your receiver's signed event and 2xx acknowledgement. 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();
});
Warning

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.