Webhooks
Register a URL and Ticketlayer POSTs a signed JSON envelope to it whenever one of the events below happens in your organisation. Payloads are constructed field by field from a fixed, versioned view (never a raw database row), so what is documented here is exactly what arrives.
Event types
| Type | When | Payload |
|---|---|---|
order.created | A new order was started | Order |
order.confirmed | An order was paid and confirmed | Order |
order.cancelled | An order was cancelled | Order |
order.refunded | An order was refunded | Order |
customer.created | A new customer was created | Customer |
event.created | A new event was created | Event |
ticket.issued | Tickets (passes) were issued for an order | Tickets issued |
ticket.redeemed | A ticket was scanned and redeemed | Ticket redeemed |
GET /v1/webhook-event-types returns this list with descriptions, which is
what the Backstage subscription UI shows.
Payloads
Money is in integer minor units with an ISO 4217 currency ("totalAmount": 2500, "currency": "GBP" is 25.00 GBP). Instants are ISO 8601 strings. Every
nullable field is present as null rather than omitted.
Order (order.*)
{
"id": "ord_01J...",
"orderNumber": "TL-10432",
"status": "confirmed",
"currency": "GBP",
"totalAmount": 5400,
"subtotalAmount": 5000,
"customerId": "cus_01J...",
"accountId": "acc_01J...",
"createdAt": "2026-09-13T19:04:11.000Z",
"lineItemCount": 2
}
Customer (customer.created)
{
"id": "cus_01J...",
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"accountId": "acc_01J...",
"createdAt": "2026-09-13T19:04:11.000Z"
}
Event (event.created)
{
"id": "evt_01J...",
"name": "Jazz Night",
"status": "draft",
"accountId": "acc_01J...",
"venueId": "vnu_01J...",
"timezone": "Europe/London",
"createdAt": "2026-09-13T19:04:11.000Z"
}
Tickets issued (ticket.issued)
{
"orderId": "ord_01J...",
"accountId": "acc_01J...",
"passCount": 2,
"passes": [
{ "id": "pas_01J...", "ticketTypeId": "tkt_01J...", "occurrenceId": "occ_01J...", "status": "issued" },
{ "id": "pas_01K...", "ticketTypeId": "tkt_01J...", "occurrenceId": "occ_01J...", "status": "issued" }
]
}
Ticket redeemed (ticket.redeemed)
{
"redemptionId": "red_01J...",
"passId": "pas_01J...",
"entitlementId": "ent_01J...",
"accountId": "acc_01J..."
}
The delivery
Each delivery is one POST with a JSON body:
{
"id": "whd_01J...",
"type": "order.confirmed",
"version": "2026-06-13",
"createdAt": "2026-09-13T19:04:12.000Z",
"data": { "id": "ord_01J...", "orderNumber": "TL-10432", "...": "..." }
}
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Ticketlayer-Webhooks/1.0 |
Ticketlayer-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Ticketlayer-Event-Type | The event type, same as type in the body |
Ticketlayer-Event-Id | The id of the source event; stable across retries of the same event |
Ticketlayer-Delivery-Id | The id of this delivery attempt series, same as id in the body |
Respond with any 2xx within 15 seconds. Anything else, or a timeout, is
retried with backoff; the delivery is marked failed when retries are
exhausted. Deliveries and their responses are listed at
GET /v1/webhook-deliveries and in Backstage.
Use Ticketlayer-Event-Id to deduplicate: a retry carries the same value.
Verify the signature
The signing secret is returned once when the endpoint is created. The
signature is an HMAC-SHA256 of "<t>.<raw body>" with that secret, hex
encoded; reject deliveries whose t is more than five minutes from now.
import crypto from 'node:crypto';
export function verifyTicketlayerSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(v1, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Compute the HMAC over the raw request body, before any JSON parsing or re-serialisation.
app.post('/webhooks/ticketlayer', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyTicketlayerSignature(raw, req.get('Ticketlayer-Signature') ?? '', process.env.TL_WEBHOOK_SECRET!)) {
return res.status(400).send('bad signature');
}
const envelope = JSON.parse(raw);
if (envelope.type === 'order.confirmed') {
// envelope.data is the Order payload above
}
res.sendStatus(204);
});
Register an endpoint
With an organisation API key or a staff token:
curl -X POST https://api.staging.t9r.dev/v1/webhook-endpoints \
-H "Authorization: Bearer tlak_..." \
-H "X-Ticketlayer-Org: your-org-slug" \
-H "Content-Type: application/json" \
-d '{
"accountId": "acc_01J...",
"url": "https://hooks.example.com/ticketlayer",
"description": "Orders to the CRM",
"eventTypes": ["order.confirmed", "ticket.issued"]
}'
The response carries the endpoint and, once only, its secret. Omit
eventTypes to receive every type. Endpoints are per account (the
organisation's sub-unit that owns the events); the same URL can be registered
for several accounts.
Operations: create,
list,
get,
update (URL,
description, event types, enabled/disabled),
delete and
test, which sends a
signed sample delivery.
Payload versions
Webhook payloads are a public contract. Each endpoint pins the payload version
current when it was created (2026-06-13 today) and keeps receiving that
shape: when a payload changes in a breaking way a new dated version is added
with a transform back to the previous one, and deliveries are transformed down
to the endpoint's pinned version before sending. The version is stamped in
every envelope as version.
Local development
Run a tunnel (for example ngrok http 3000) and register the tunnel URL as
the endpoint. The test operation above is the quickest way to see a
delivery without buying a ticket.