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

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

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 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 }),
    },
  );
}
server/buyer-access-route.jsjs
// 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);
});
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());
  await tickets.issueAndDeliver(order.id);

  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.
  • 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 before adapting this to production.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close