Draft API. Endpoints may change before general availability.
Webhooks
A webhook is us calling you. When something happens, like an order being paid, we send a POST request to a URL on your server, so you don't have to keep asking.
Set one up in three steps
- Add a route to your server that accepts POST requests, for example
/webhooks/eventtickets. - In the dashboard, open Developers, add that URL under New webhook endpoint, and copy the signing secret it shows you.
- Press Send test. Your route should receive a
pingevent and the delivery log should say Delivered.
Live endpoints must be public https:// addresses. Test endpoints can also be http://, which is handy with a tunnel to your own computer.
Events we send
- order.paid
- Someone paid. The tickets exist and have been emailed. This is the one most sites need.
- order.refunded
- An order was refunded. Its tickets no longer scan.
- ticket.issued
- A ticket was created: one per ticket in a paid order, and for each guest-list ticket.
- ticket.redeemed
- A ticket was scanned in at the door: its first entry on that day.
- ticket.readmitted
- A ticket was scanned back in on the same day (events that allow re-entry).
- ticket.exited
- A ticket was scanned out (events that scan in and out).
- ticket.voided
- A guest-list ticket was cancelled.
- ping
- Only sent by the Send test button.
Door events include scanned_session_id next to the ticket, so on multi-day events you know which day they came in on.
You choose which of these each endpoint receives. If in doubt, pick all of them and ignore the ones you don't need.
What arrives
Every webhook has the same envelope. type says what happened, and data.object is the full object, exactly as the API returns it.
order.paid
{
"id": "wevt_5a1c9e3b7d2f4a60",
"object": "event",
"type": "order.paid",
"livemode": true,
"created_at": "2026-11-02T19:04:11.512Z",
"data": {
"object": {
"id": "ord_9c1e4b7a2d3f5e60",
"object": "order",
"event_id": "evt_3f9a1c2b7d4e5f60",
"status": "paid",
"currency": "gbp",
"amount_subtotal": 2400,
"amount_discount": 0,
"amount_addons": 0,
"amount_fee": 132,
"fee_payer": "buyer",
"amount_total": 2532,
"buyer_email": "ama@example.com",
"buyer_name": "Ama Owusu",
"metadata": { "customer_id": "4821" },
"tickets": [
{ "id": "tkt_1b2c3d4e5f607182", "object": "ticket", "ticket_type_id": "tt_5d1e7f2a9b3c4d60", "tier_label": "Standard", "status": "valid", "url": "https://pay.eventtickets.dev/t/..." },
{ "id": "tkt_2c3d4e5f60718293", "object": "ticket", "ticket_type_id": "tt_5d1e7f2a9b3c4d60", "tier_label": "Standard", "status": "valid", "url": "https://pay.eventtickets.dev/t/..." }
],
"addons": []
}
}
}The metadata you passed when creating the checkout session comes back on the order, so you can match the sale to a customer in your own system.
Check it really came from us
Anyone can POST to your URL, so check the signature before trusting a webhook. Each request has an EventTickets-Signature header that looks like t=1793560000,v1=5f2b…. It's an HMAC-SHA256 of the time and the body, made with your signing secret. Work it out yourself and compare.
Node.js (Next.js route handler)
import crypto from "node:crypto";
export async function POST(req) {
const body = await req.text(); // the raw body, before JSON.parse
const header = req.headers.get("eventtickets-signature") ?? "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.EVENTTICKETS_WEBHOOK_SECRET)
.update(`${t}.${body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
const valid = v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !valid) return new Response("Bad signature", { status: 400 });
const event = JSON.parse(body);
if (event.type === "order.paid") {
// Mark the order paid in your database, send your own thank-you email, etc.
}
return new Response("ok"); // any 2xx says "got it"
}Two common mistakes: checking the signature against parsed-then-re-stringified JSON (use the raw body), and comparing with == (use a constant-time compare like above).
Timing and retries
Webhooks usually arrive within a minute. Reply with any 2xx status within 10 seconds; do slow work afterwards. If you don't, we try again after 1, 5 and 30 minutes, then 2, 5, 10 and 10 hours: eight tries over about a day and a half. Every attempt shows in the dashboard's delivery log, where you can also press Retry.
Because of retries, the same event can reach you twice. Save each event's id and skip any you've already handled.
GET /v1/orders to catch up.