Webhooks
Webhooks let your systems receive real-time HTTP POST notifications when queue events happen — a ticket joins, is called, completes, and so on.
Getting started
Section titled “Getting started”- Open admin.jonot.io/settings/integrations.
- Click Add endpoint.
- Enter a public HTTPS URL your server listens on.
- Choose which events to subscribe to.
- Click Save.
Save your signing secret — it is shown once. To verify your endpoint is reachable, use the Send test request button on the endpoint edit page.
Event types
Section titled “Event types”| Event name | When it fires |
|---|---|
ticket.joined | A customer joins a queue |
ticket.called | A staff member calls a ticket to the service desk |
ticket.completed | A ticket is marked complete |
ticket.cancelled | A customer or staff member cancels a ticket |
ticket.skipped | A ticket is skipped (a recallable deferral) |
ticket.no_show | A called or skipped ticket is confirmed as a no-show |
queue.status_changed | A queue's status changes (ACTIVE, PAUSED, or CLOSED) |
Payload format
Section titled “Payload format”Every delivery is an HTTP POST with:
Content-Type: application/jsonX-Jonot-Event: ticket.calledX-Jonot-Delivery-Id: <uuid>X-Jonot-Timestamp: 2024-06-01T12:00:00.000ZX-Jonot-Signature: v1,<base64-hmac-sha256>X-Jonot-Payload-Version: v1The default body is a JSON envelope. The payload field shape varies by event:
ticket.joined
{ "deliveryId": "<uuid>", "event": "ticket.joined", "timestamp": "2024-06-01T12:00:00.000Z", "org": { "id": "org_…", "name": "My Org" }, "payload": { "ticket": { "id": "tkt_…", "queueId": "q_…" }, "queue": { "id": "q_…", "name": "Main Queue" }, "location": { "id": "loc_…", "name": "Downtown" } }}ticket.called / ticket.completed / ticket.skipped / ticket.no_show
{ "deliveryId": "<uuid>", "event": "ticket.called", "timestamp": "2024-06-01T12:00:00.000Z", "org": { "id": "org_…", "name": "My Org" }, "payload": { "ticket": { "id": "tkt_…", "number": 42, "status": "CALLED", "queueId": "q_…", "createdAt": "2024-06-01T11:58:00.000Z", "updatedAt": "2024-06-01T12:00:00.000Z" } }}ticket.cancelled
{ "deliveryId": "<uuid>", "event": "ticket.cancelled", "timestamp": "2024-06-01T12:00:00.000Z", "org": { "id": "org_…", "name": "My Org" }, "payload": { "ticketId": "tkt_…", "queueId": "q_…" }}queue.status_changed
{ "deliveryId": "<uuid>", "event": "queue.status_changed", "timestamp": "2024-06-01T12:00:00.000Z", "org": { "id": "org_…", "name": "My Org" }, "payload": { "queueId": "q_…", "status": "PAUSED" }}Verifying the signature
Section titled “Verifying the signature”Jonot signs every delivery with HMAC-SHA256 over the string:
<deliveryId>.<timestamp>.<body>To verify in Node.js (≥18):
import { createHmac, timingSafeEqual } from "node:crypto";
/** * Returns true when the signature header is valid and the timestamp is * within 5 minutes of now. Throws for malformed input. */function verifySignature(secret, deliveryId, timestamp, body, header) { // Replay-attack guard: reject deliveries older than 5 minutes. const ageMs = Date.now() - new Date(timestamp).getTime(); if (Math.abs(ageMs) > 5 * 60 * 1000) return false;
const expected = "v1," + createHmac("sha256", secret) .update(`${deliveryId}.${timestamp}.${body}`) .digest("base64");
// timingSafeEqual prevents timing-oracle attacks. // Buffers must be the same length — if lengths differ the signature is // invalid, but we still compare a dummy value to keep constant time. const expectedBuf = Buffer.from(expected); const headerBuf = Buffer.from(header); if (expectedBuf.length !== headerBuf.length) return false; return timingSafeEqual(expectedBuf, headerBuf);}Payload templates
Section titled “Payload templates”You can replace the default JSON body with a custom template. Templates use Mustache-lite syntax:
{{ path.to.value }}— substituted and escaped for the content type: JSON-string escaped forapplication/json, percent-encoded forapplication/x-www-form-urlencoded, raw fortext/plain{{{ path.to.value }}}— substituted raw (no escaping)
Example template for application/json (subscribed to ticket.joined):
{ "type": "{{ event }}", "ticketId": "{{ payload.ticket.id }}", "queueName": "{{ payload.queue.name }}"}Template editor
Section titled “Template editor”The payload template field is a full code editor with:
- Syntax highlighting and bracket matching for JSON templates.
- Variable palette — an always-visible row of insert buttons, one per available variable path for your selected events. Click a button to insert a
{{ path }}token at the cursor. - Live validation — as you type, the editor checks both JSON structure (when the content type is
application/jsonand no raw{{{ }}}tags are present) and variable paths. Diagnostics appear as inline markers in the editor and as a summary banner below it:- Error — a path that is unknown in all of your selected events.
- Warning — a path that exists only for some of your selected events (it will be empty for deliveries of the other events).
Validation also runs at save time on the server — the editor feedback mirrors the server rules exactly.
Delivery retries
Section titled “Delivery retries”Failed deliveries (non-2xx or connection error) are retried up to 3 times with exponential back-off by Cloudflare Queues. After 3 failures the delivery lands in the dead-letter queue and the endpoint's consecutive failures counter increments.
Once an endpoint accumulates 20 consecutive failures it is automatically disabled. Re-enable it from the endpoint edit page; the counter resets to zero.
Delivery history
Section titled “Delivery history”The Deliveries tab on each endpoint shows the last 30 days of delivery attempts: event type, HTTP status, attempt count, and timestamp. Use the Load more button to page through older records.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Endpoints per organisation | 5 |
| Custom headers per endpoint | 10 |
| Header value length | 1 024 bytes |
| Payload template size | 16 KB |
| Delivery timeout | 10 s |
| Delivery history retention | 30 days |
| Max delivery rate per organisation | 120 / 60 s |
Rotating the signing secret
Section titled “Rotating the signing secret”- Open the endpoint edit page.
- Click Rotate signing secret.
- Confirm the rotation in the dialog.
- Copy the new secret immediately — it is shown once and cannot be recovered. If you dismiss the dialog without saving it, you must rotate again to obtain a new plaintext.
- Update your server to verify signatures with the new secret.
There is no overlap window. The old secret stops verifying as soon as you confirm rotation. Plan to deploy the new secret to your receiver immediately after rotating.
The UI shows Active secret: ····XXXX (the last four characters) next to the Rotate button so you can verify which secret is currently in effect — useful for confirming that your receiver and the server are in sync after a rotation.
Signing-secret hygiene
Section titled “Signing-secret hygiene”When to rotate:
- Suspected compromise: the secret appeared in a log file, was shared with a departing employee, or was captured in a screen recording.
- Routine hygiene: periodically rotating limits the blast radius of an undetected exposure.
- After any change to which services can read the secret (key management rotation).
How to update the receiver:
- Rotate in the admin UI and copy the new secret.
- Update the secret in your receiver's secret store (environment variable, secrets manager, etc.).
- Deploy the updated receiver.
- Confirm the next delivery succeeds in the Deliveries tab.
If you lost the new secret before saving it:
Rotate again. Each rotation generates a new random secret. The previous plaintext is not recoverable — only an AEAD-encrypted form is stored server-side.