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

Use this pattern when you already own the event catalogue, buyer, checkout,
payment, commercial Orders, receipts, tickets, delivery, scanning, and refunds.
SeatLayer owns live inventory. The buyer/server handoff is `holdId`; your stable
`bookingRef` links the resulting inventory booking to your commercial order.

<Aside type="note" title="Platform builders use the same purchase flow">
  Multi-tenant products add [workspace-scoped](/platform/workspaces/)
  provisioning and webhook routing,
  but the browser-hold and server-book boundary does not change.
</Aside>

## The transaction boundary

<FlowDiagram
  label="Ticketing-backend transaction flow"
  steps={[
    {
      actor: "Buyer",
      title: "Selects in the picker",
      detail: "The buyer chooses seats through the SeatLayer buyer SDK in your application.",
      tone: "buyer",
    },
    {
      actor: "Buyer SDK",
      title: "Creates the temporary hold",
      detail: "The browser sends only the opaque hold ID to your checkout route.",
      tone: "seatlayer",
    },
    {
      actor: "Your server",
      title: "Reads authoritative hold items",
      detail: "Your backend validates the active hold and calculates the trusted order amount from its returned items.",
      tone: "platform",
    },
    {
      actor: "Your platform",
      title: "Creates the order and runs payment",
      detail: "Your own order ID becomes the stable booking reference for this purchase.",
      tone: "payment",
    },
    {
      actor: "SeatLayer",
      title: "Books atomically",
      detail: "A successful booking confirms the order. A 409 is an inventory conflict to recover, not a partial sale.",
      tone: "seatlayer",
    },
    {
      actor: "Your server",
      title: "Reconciles signed events",
      detail: "Process webhooks idempotently to repair or confirm downstream records after the synchronous result.",
      tone: "outcome",
    },
  ]}
/>

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

<Steps>
  <Step title="Configure the buyer's browser-access lane">
    For an ordinary Platform Public sale, register the checkout page's exact
    Embed domain and pass the matching account `publicKey`; SeatLayer returns a
    Public-only in-memory session with the chart and compact inventory snapshot.
    For login, presale, partner, or channel inventory, your authenticated backend
    instead calls
    [`POST /v1/events/:key/buyer-access-sessions`](/server-api/buyer-access-sessions/)
    and supplies the result through `buyerAccessTokenProvider`. Managed
    public/unlisted events need neither browser credential.
  </Step>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
  <Step title="Book atomically">
    Call the book endpoint using the order id as `bookingRef`. Reuse that exact
    value for every retry of the same purchase.
  </Step>
  <Step title="Confirm, fulfil, and reconcile">
    Drive the buyer response from the synchronous booking result, then create
    and deliver tickets from your platform. Process signed inventory webhooks
    idempotently to repair or confirm downstream state.
  </Step>
</Steps>

## 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",
  publicKey: "pk_test_…", // ordinary Public Platform sale
  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();
```

For a scoped private audience, replace `publicKey` with a server-backed
`buyerAccessTokenProvider`. The hold and booking boundary does not change.

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

<Aside type="note" title="Every chart category is sellable by default">
  The Buyer SDK offers every category on the published chart. If your platform
  prices tickets per category, make sure each chart category has a matching
  ticket before the event goes on sale — or restrict the buyer with
  `selectableObjects` — otherwise a buyer can hold seats your checkout cannot
  price. Pass your own prices with `pricing.prices` so the map shows what
  checkout will charge.
</Aside>

## 2. Inspect authoritative items

<ApiEndpoint method="GET" path="/v1/events/:eventKey/holds/:holdId" auth="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

<ApiEndpoint method="POST" path="/v1/events/:eventKey/book" auth="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);
await issueAndDeliverYourTickets(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 ticket cancellation, monetary refund, and
SeatLayer inventory cancellation as observable operations in your system.
SeatLayer releases inventory; it does not move money or cancel/deliver your
tickets.

<Aside type="caution" title="Inventory booking is not SeatLayer fulfilment">
  The `/book` call does not create a supported SeatLayer commerce Order,
  ticket, QR/PDF/email, attendee/door record, or refund for this product. Keep
  those records in your platform even if an older deployment returns extra
  commerce-shaped fields.
</Aside>

## 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`.
- [ ] Our platform creates, delivers, recovers, and scans its own tickets.
- [ ] 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).