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.
The transaction boundary
BuyerSelects in the picker
The buyer chooses seats through the SeatLayer buyer SDK in your application.
Buyer SDKCreates the temporary hold
The browser sends only the opaque hold ID to your checkout route.
Your serverReads authoritative hold items
Your backend validates the active hold and calculates the trusted order amount from its returned items.
Your platformCreates the order and runs payment
Your own order ID becomes the stable booking reference for this purchase.
SeatLayerBooks atomically
A successful booking confirms the order. A 409 is an inventory conflict to recover, not a partial sale.
Your serverReconciles signed events
Process webhooks idempotently to repair or confirm downstream records after the synchronous result.
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
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
and supplies the result through buyerAccessTokenProvider. Managed
public/unlisted events need neither browser credential.
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.
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.
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.
Book atomically
Call the book endpoint using the order id as bookingRef. Reuse that exact
value for every retry of the same purchase.
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.
1. Create and hand off the hold
With the headless chart, call hold() after selection:
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.
2. Inspect authoritative items
/v1/events/:eventKey/holds/:holdIdSecret keyconst 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
/v1/events/:eventKey/bookSecret keyconst 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
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.
Production checklist
- The browser sends only
holdIdand 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
409is 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, idempotency and conflict behavior, or webhook signatures.