Use this pattern when you already own checkout, payment, orders, receipts, and
refunds. SeatLayer owns live inventory. The systems meet at one opaque value:
holdId.
The transaction boundary
Buyer
└─ selects in SeatLayer buyer SDK
└─ SDK creates a temporary hold
└─ browser sends holdId to your server
├─ your server inspects authoritative hold items
├─ your server creates an order and runs payment
└─ your server books with secret key + bookingRef
├─ 200: confirm the order
└─ 409: recover as an inventory conflict
Signed webhooks reconcile your downstream records after the synchronous flow.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
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 and reconcile
Drive the buyer response from the synchronous booking result. Process signed 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",
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();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);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 monetary refund and SeatLayer cancellation as two observable operations. SeatLayer releases inventory; it never moves the buyer’s money.
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. - 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.