Developers

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

InternalPublic
ticket.createdqueue.entry.created
ticket.callNextqueue.entry.called
ticket.statusChangedqueue.entry.updated, plus cancelled/completed when the target status matches the location workflow leave/complete operations

Setup

  1. Organisation settings → Integrations → Webhooks.
  2. HTTPS URL, subscribe to events, copy the signing secret once (esperaly_whsec_…).
  3. 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");
  }
}

All developer docs