Skip to main content

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

TypeWhenPayload
order.createdA new order was startedOrder
order.confirmedAn order was paid and confirmedOrder
order.cancelledAn order was cancelledOrder
order.refundedAn order was refundedOrder
customer.createdA new customer was createdCustomer
event.createdA new event was createdEvent
ticket.issuedTickets (passes) were issued for an orderTickets issued
ticket.redeemedA ticket was scanned and redeemedTicket 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", "...": "..." }
}
HeaderValue
Content-Typeapplication/json
User-AgentTicketlayer-Webhooks/1.0
Ticketlayer-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Ticketlayer-Event-TypeThe event type, same as type in the body
Ticketlayer-Event-IdThe id of the source event; stable across retries of the same event
Ticketlayer-Delivery-IdThe 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.

Express
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.