Skip to content

Complete checkout

A framework-neutral browser and Node server example covering hold inspection, payment, booking, retries, and conflicts.

Updated View as Markdown

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.

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

server/seatlayer.jsjs
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 }),
    },
  );
}
server/checkout-route.jsjs
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`,
  });
});

Retry worker

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

server/retry-booking.jsjs
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 before adapting this to production.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close