Webhooks
Signed HTTPS events for queue entries
The same ticket domain events that power in-app email automations fan out to your HTTPS endpoint. There is not a second event bus.
Public events
| Internal | Public |
|---|---|
| ticket.created | queue.entry.created |
| ticket.callNext | queue.entry.called |
| ticket.statusChanged | queue.entry.updated, plus cancelled/completed when the target status matches the location workflow leave/complete operations |
Setup
- Organisation settings → Integrations → Webhooks.
- HTTPS URL, subscribe to events, copy the signing secret once (esperaly_whsec_…).
- Delivery POSTs JSON. Failed deliveries retry with backoff and auto-disable after 10 consecutive failures. Admins can retry from the UI.
Localhost, private, link-local, and metadata IPs are rejected. Redirects are not followed. When the Esperaly site URL is https, endpoints must be https.
Payload
{
"id": "queue.entry.created:…",
"type": "queue.entry.created",
"apiVersion": "v1",
"createdAt": 1710000000000,
"data": {
"id": "<ticket id>",
"queueId": "<location id>",
"serviceId": "<service id>",
"code": "A001",
"ticketNumber": 1,
"statusId": "st_waiting",
"customerName": "Ada",
"createdAt": 1710000000000,
"updatedAt": 1710000000000
}
}
id is stable for deduplication.
Signature verification
- Esperaly-Timestamp — Unix seconds
- Esperaly-Signature — t=<timestamp>,v1=<hmac-sha256-hex>
Signed string: {timestamp}.{rawBody} using the endpoint signing secret. Reject requests whose timestamp is older (or newer) than 5 minutes to prevent replay.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || Math.abs(nowSeconds - timestamp) > 300) {
throw new Error("replay");
}
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const actual = Buffer.from(parts.v1 ?? "", "utf8");
const wanted = Buffer.from(expected, "utf8");
if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) {
throw new Error("mismatch");
}
}