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

  1. Add a route to your server that accepts POST requests, for example /webhooks/eventtickets.
  2. In the dashboard, open Developers, add that URL under New webhook endpoint, and copy the signing secret it shows you.
  3. Press Send test. Your route should receive a ping event 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.

Webhooks are a hint to act, not the only record. If your server was down for days, list recent orders with GET /v1/orders to catch up.