---
title: "Complete checkout"
description: "A framework-neutral browser and Node server example covering hold inspection, payment, booking, retries, and conflicts."
---

This example shows the smallest production-shaped Platform/SDK loop. Adapt the
routing and payment calls to your framework; keep the trust boundary and state
transitions intact. Your application owns the commercial Order and creates,
delivers, recovers, and scans its own tickets.

## Browser

The browser renders inventory, creates a hold, and sends only the hold id to your backend.

```html title="checkout.html"
<div id="seat-picker" style="height: min(720px, 75vh)"></div>
<p id="checkout-error" role="alert"></p>

<script src="https://cdn.seatlayer.io/seatlayer-js@0/seatlayer-buyer.js"></script>
<script type="module">
  const error = document.querySelector("#checkout-error");

  const picker = new seatlayer.SeatPicker({
    container: "#seat-picker",
    event: window.SEATLAYER_EVENT_KEY,
    publicKey: window.SEATLAYER_PUBLIC_KEY,
    onCheckout: async (_, __, handoff) => {
      error.textContent = "";

      const response = await fetch(
        `/api/events/${window.BUSINESS_EVENT_ID}/checkout/seats`,
        {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ holdId: handoff.holdId }),
        },
      );

      if (response.status === 409) {
        error.textContent =
          "Your reservation changed. Please choose the seats again.";
        return;
      }

      if (!response.ok) {
        error.textContent = "Checkout could not continue. Please try again.";
        return;
      }

      const { redirectUrl } = await response.json();
      window.location.assign(redirectUrl);
    },
  });

  await picker.render();
</script>
```

Register this page's exact origin as an Embed domain. The public key starts one
direct Public-sale bootstrap; SeatLayer returns the chart and compact inventory
status together, and the SDK keeps the resulting bearer in memory. For a
login, presale, partner, or channel audience, replace `publicKey` with a scoped
`buyerAccessTokenProvider` from your authenticated backend.

The buyer never posts a price, category, or trusted seat allocation. Those
values are resolved from the hold by the server.

## Server

<Aside type="note" title="This server helper is private-audience only">
  Do not mint a buyer-access token for the ordinary Public sale shown in the
  browser example above. That page passes `publicKey` and starts directly with
  SeatLayer. Add the following helper only when an authenticated buyer must see
  login-, presale-, partner-, or channel-scoped inventory.
</Aside>

```js title="server/seatlayer.js"
const SEATLAYER_API = "https://api.seatlayer.io";

async function seatlayer(path, init = {}) {
  const response = await fetch(`${SEATLAYER_API}${path}`, {
    ...init,
    headers: {
      authorization: `Bearer ${process.env.SEATLAYER_SECRET_KEY}`,
      "content-type": "application/json",
      ...init.headers,
    },
  });

  return response;
}

export async function mintPrivateBuyerAccess(eventKey, allowedOrigin) {
  const response = await seatlayer(
    `/v1/events/${encodeURIComponent(eventKey)}/buyer-access-sessions`,
    {
      method: "POST",
      body: JSON.stringify({
        // A private agency allocation. Both fields are deliberate: SeatLayer
        // has no default for includePublic and an empty scope is rejected.
        channelIds: ["chn_agency_private"],
        includePublic: false,
        // One exact HTTPS origin — the origin your checkout page is served
        // from. SeatLayer checks it on every request the token makes.
        allowedOrigin,
      }),
    },
  );
  if (!response.ok) {
    throw new Error(`SeatLayer buyer access mint failed: ${response.status}`);
  }
  const session = await response.json();
  // Keep sessionId for audit/revocation; hand only these two to the browser.
  return { token: session.token, expiresAt: session.expiresAt };
}

export async function inspectHold(eventKey, holdId) {
  const response = await seatlayer(
    `/v1/events/${encodeURIComponent(eventKey)}/holds/${encodeURIComponent(holdId)}`,
  );

  if (response.status === 404 || response.status === 409) return null;
  if (!response.ok) {
    throw new Error(`SeatLayer hold inspection failed: ${response.status}`);
  }
  return response.json();
}

export async function bookHold(eventKey, holdId, bookingRef) {
  return seatlayer(
    `/v1/events/${encodeURIComponent(eventKey)}/book`,
    {
      method: "POST",
      body: JSON.stringify({ holdId, bookingRef }),
    },
  );
}
```

```js title="server/buyer-access-route.js"
// Private-audience alternative to the publicKey bootstrap above. Gate this
// route according to who may see the login, presale, partner, or channel
// inventory. The token cannot book; booking stays with your server.
app.post("/api/events/:eventId/seat-access", async (req, res) => {
  const event = await db.events.findById(req.params.eventId);
  if (!event?.seatlayerEventKey) {
    return res.status(404).json({ error: "event_not_found" });
  }
  // Bind the token to the page's origin. Never trust a body-supplied origin;
  // derive it from the request (Origin header) or your configured site URL.
  const allowedOrigin = req.get("origin") ?? process.env.SHOP_ORIGIN;
  const session = await mintPrivateBuyerAccess(
    event.seatlayerEventKey,
    allowedOrigin,
  );
  res.set("cache-control", "no-store");
  res.json(session);
});
```

```js title="server/checkout-route.js"
app.post("/api/events/:eventId/checkout/seats", async (req, res) => {
  const holdId =
    typeof req.body?.holdId === "string" ? req.body.holdId : "";
  if (!holdId) return res.status(400).json({ error: "holdId_required" });

  const event = await loadBusinessEvent(req.params.eventId);
  const hold = await inspectHold(event.seatlayerKey, holdId);
  if (!hold || hold.status !== "active") {
    return res.status(409).json({ error: "hold_not_active" });
  }

  // Create from trusted server-returned items.
  const order = await orders.create({
    customerId: req.user.id,
    eventId: event.id,
    items: hold.items,
    currency: hold.items[0]?.currency,
    amount: hold.items.reduce(
      (total, item) => total + item.unitPrice * item.quantity,
      0,
    ),
  });

  // Prefer authorization before booking when your provider supports it.
  const payment = await payments.authorize({
    orderId: order.id,
    amount: order.amount,
    currency: order.currency,
  });

  const booking = await bookHold(
    event.seatlayerKey,
    holdId,
    order.id, // stable idempotency reference
  );

  if (booking.status === 409) {
    await payments.void(payment.id);
    await orders.markInventoryConflict(order.id);
    return res.status(409).json({ error: "inventory_conflict" });
  }

  if (!booking.ok) {
    // The booking result may be unknown after a network/server failure.
    // Queue a retry with the SAME order.id; do not issue a new bookingRef.
    await retries.enqueueSeatLayerBooking({
      eventKey: event.seatlayerKey,
      holdId,
      bookingRef: order.id,
      paymentId: payment.id,
    });
    return res.status(202).json({
      redirectUrl: `/orders/${order.id}/processing`,
    });
  }

  await payments.capture(payment.id);
  await orders.markBooked(order.id, await booking.json());
  await tickets.issueAndDeliver(order.id);

  return res.json({
    redirectUrl: `/orders/${order.id}/confirmation`,
  });
});
```

<Aside type="caution" title="Your route needs its normal application security">
  Authenticate the customer, authorize access to the business event, validate request size/type, rate-limit abuse, and apply CSRF protection when your session model requires it.
</Aside>

## Retry worker

A timeout is an unknown result, not a failed result. Retry with the same order id:

```js title="server/retry-booking.js"
async function retrySeatLayerBooking(job) {
  const response = await bookHold(
    job.eventKey,
    job.holdId,
    job.bookingRef,
  );

  if (response.ok) {
    await payments.capture(job.paymentId);
    await orders.markBooked(job.bookingRef, await response.json());
    return;
  }

  if (response.status === 409) {
    await payments.void(job.paymentId);
    await orders.markInventoryConflict(job.bookingRef);
    return;
  }

  throw new Error(`Retryable SeatLayer error: ${response.status}`);
}
```

## What to adapt

- Resolve the SeatLayer event key from your own trusted event record, not from arbitrary browser input.
- Expose only the matching publishable key to the browser, register the exact
  Embed origin, and never expose `SEATLAYER_SECRET_KEY`.
- Use your actual authentication and authorization middleware.
- Use minor currency units if your payment provider requires them.
- Keep order creation idempotent if the browser repeats the checkout POST.
- Follow your provider's authorization, capture, void, and refund semantics.
- Create and deliver tickets only after the inventory booking is confirmed.
- Keep ticket recovery, scanning, and attendance in your platform.
- Add a signed webhook receiver for reconciliation.

## Tests worth keeping

| Test | Expected result |
|---|---|
| Valid active hold | One order, one booking, payment captured |
| Repeated checkout POST | Existing order/reference reused |
| Hold expired before inspection | `409`, no payment attempted |
| Booking conflict after authorization | Payment voided, order recoverable |
| Booking response times out | Processing state, same reference retried |
| Retry after successful first booking | Idempotent success |
| Browser posts fake price | Ignored; hold items determine amount |
| Missing secret key | Safe server error, no browser secret |

Read [integration best practices](/integrations/best-practices) before adapting this to production.