---
title: "Integrate a ticketing backend"
description: "Connect SeatLayer holds and bookings to your existing order, payment, and reconciliation flow."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.seatlayer.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrate a ticketing backend

Use this pattern when you already own checkout, payment, orders, receipts, and
refunds. SeatLayer owns live inventory. The systems meet at one opaque value:
`holdId`.

> **Platform builders use the same purchase flow**
>
> Multi-tenant products add workspace-scoped provisioning and webhook routing,
> but the browser-hold and server-book boundary does not change.

## The transaction boundary

```text title="ticketing-transaction.txt"
Buyer
  └─ selects in SeatLayer buyer SDK
       └─ SDK creates a temporary hold
            └─ browser sends holdId to your server
                 ├─ your server inspects authoritative hold items
                 ├─ your server creates an order and runs payment
                 └─ your server books with secret key + bookingRef
                      ├─ 200: confirm the order
                      └─ 409: recover as an inventory conflict

Signed webhooks reconcile your downstream records after the synchronous flow.
```

The browser must not post trusted prices, seat labels, or a booking request.
Those values either come from hold inspection or are owned by your server.

## Implement the flow

1. **Create the hold in the browser**

   Let `SeatPicker` hold during its checkout action, or explicitly call
   `SeatingChart.hold()`. Persist the resulting `holdId` with the in-progress
   order.
2. **Inspect the hold on your server**

   Resolve the hold using a secret key. Reject an expired hold and calculate
   the order from the returned items rather than browser input.
3. **Run payment and order logic**

   Create a durable order id. Prefer a payment authorization that can be
   captured after booking when your payment provider supports it; otherwise
   define the void/refund compensation path before launch.
4. **Book atomically**

   Call the book endpoint using the order id as `bookingRef`. Reuse that exact
   value for every retry of the same purchase.
5. **Confirm and reconcile**

   Drive the buyer response from the synchronous booking result. Process signed
   webhooks idempotently to repair or confirm downstream state.

## 1. Create and hand off the hold

With the headless chart, call `hold()` after selection:

```js title="browser/seats.js"
const chart = new seatlayer.SeatingChart({
  container: "#chart",
  event: "ev_9f3a",
  onHold: async ({ holdId, expiresAt }) => {
    await fetch("/api/checkout/start", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ holdId, expiresAt }),
    });
  },
});

await chart.render();
await chart.hold();
```

`SeatPicker` performs the hold as part of its CTA and exposes the same boundary
through `onCheckout(_, _, handoff)`. Prefer its third `handoff` argument for new
integrations.

## 2. Inspect authoritative items

**GET /v1/events/:eventKey/holds/:holdId** — Authentication: Secret key

```js title="server/inspect-hold.js"
const holdResponse = await fetch(
  `https://api.seatlayer.io/v1/events/${eventKey}/holds/${holdId}`,
  {
    headers: {
      authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`,
    },
  },
);

if (!holdResponse.ok) {
  return reply.status(409).send({ code: "hold_unavailable" });
}

const hold = await holdResponse.json();
if (hold.status !== "active" || hold.expiresAt <= Date.now()) {
  return reply.status(409).send({ code: "hold_unavailable" });
}

const total = hold.items.reduce(
  (sum, item) => sum + item.unitPrice * (item.quantity ?? 1),
  0,
);
```

Use the returned currency, tier, quantity, and price fields supported by your
order model. Treat the browser handoff as display context, never the charging
authority.

## 3. Book after the payment decision

**POST /v1/events/:eventKey/book** — Authentication: Secret key

```js title="server/book-order.js"
const bookingResponse = await fetch(
  `https://api.seatlayer.io/v1/events/${eventKey}/book`,
  {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      holdId,
      bookingRef: order.id,
    }),
  },
);

if (bookingResponse.status === 409) {
  await voidOrRefundPayment(order);
  return reply.status(409).send({ code: "inventory_conflict" });
}

if (!bookingResponse.ok) {
  // State may be uncertain after a timeout or transport failure.
  // Retry with the same order.id—never mint a new bookingRef.
  throw new Error(`SeatLayer booking failed: ${bookingResponse.status}`);
}

await markOrderConfirmed(order.id);
```

`bookingRef` is your idempotency boundary. If a request times out, retry the same
purchase with the same value. A confirmed replay is safe, but the current replay
response contains `booked: []`; keep the durable receipt details in your order
record and webhook handler.

## Choose a payment sequence

| Sequence | Best when | Required recovery |
|---|---|---|
| Authorize → book → capture | Your provider supports separate authorization and capture | Void authorization if booking conflicts |
| Charge → book | Immediate capture is required | Refund promptly on conflict and monitor compensation failures |
| Book → charge | Only for trusted/no-payment flows where unpaid inventory is acceptable | Cancel booking if payment fails |

Do not hide this tradeoff inside a generic “checkout failed” branch. Record each
transition so support staff can distinguish payment state from inventory state.

## Reconcile signed webhooks

```js title="server/webhook.js"
app.post("/webhooks/seatlayer", rawBodyMiddleware, async (req, res) => {
  verifySeatLayerSignature(req.rawBody, req.headers);

  const { event, payload } = JSON.parse(req.rawBody);
  if (event === "seat.booked") {
    await confirmOrderIdempotently(payload);
  }

  res.sendStatus(200);
});
```

Webhooks are the reconciliation path, not a reason to keep the buyer waiting.
Verify the signature against the raw request body, return a successful status
quickly, and process every delivery idempotently.

## Expected failure behavior

| Failure | Application response |
|---|---|
| Hold is expired before payment | Clear the stale cart and return to selection |
| Booking returns `409` | Void/refund as needed; do not partially confirm |
| Booking response times out | Retry with the same `bookingRef` |
| Payment succeeds but booking remains uncertain | Keep an explicit recovery state and reconcile/retry |
| Webhook arrives twice | Deduplicate by delivery/event identity and order state |
| Webhook arrives before local confirmation commits | Upsert idempotently rather than rejecting it |

To reverse a completed sale, run your monetary refund and SeatLayer cancellation
as two observable operations. SeatLayer releases inventory; it never moves the
buyer’s money.

## Production checklist

- [ ] The browser sends only `holdId` and non-authoritative UI context.
- [ ] Hold inspection happens with a server-only secret key.
- [ ] Price and currency are derived from the inspected hold.
- [ ] One durable order id is reused as `bookingRef`.
- [ ] Payment compensation for `409` is tested.
- [ ] Timeout retries use the same request identity.
- [ ] The webhook handler verifies the raw body and is idempotent.
- [ ] Support can see payment state and inventory state separately.
- [ ] Test mode has completed a full purchase and duplicate retry.

Continue with the [booking endpoint](/server-api/booking),
[idempotency and conflict behavior](/server-api/idempotency-and-conflicts), or
[webhook signatures](/webhooks/signatures).

Source: https://docs.seatlayer.io/integrations/ticketing-backend/index.mdx
