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

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

# Complete checkout

This example shows the smallest production-shaped SeatLayer loop. Adapt the routing and payment calls to your framework; keep the trust boundary and state transitions intact.

## 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.js"></script>
<script type="module">
  const error = document.querySelector("#checkout-error");

  const picker = new seatlayer.SeatPicker({
    container: "#seat-picker",
    event: window.SEATLAYER_EVENT_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>
```

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

## Server

```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 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/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());

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

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

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

Source: https://docs.seatlayer.io/examples/complete-checkout/index.mdx
