When a buyer picks tickets, reserve them first, then show the form. A hold takes the
tickets out of everyone else’s available for the event’s hold window (default 10 minutes,
event.hold_minutes). Checkout then takes the hold over, so the tickets the buyer was
promised are the tickets they get. Two buyers can never both hold the last ticket.
Holds are optional for the API but mandatory for a good storefront: without one, a buyer
can spend five minutes filling in their details and get INSUFFICIENT_TICKETS at the end.
available on every ticket row already excludes other buyers’ holds. A ticket type can
read “Agotado” with no ticket sold yet, and come back on sale within a minute when those
holds expire. Don’t “correct” it client-side.
The flow
Session key
Every hold belongs to a session key: an opaque string of 8 to 128 characters that identifies
the buyer’s browser, never the person. Generate it once (a UUID in an httpOnly cookie is the
right place) and send the same value on every hold call. One session has one active hold per
event: a new POST replaces the previous one. A hold is read or changed only with its own key.
Keep the session key server-side (a cookie your backend reads) when you can. A key that lives
in localStorage lets one tab release another visitor’s reservation if it leaks.
1. Reserve
Show a countdown from expires_at. When it reaches zero the tickets are back on sale, and the
only way forward is a new selection (which makes a fresh hold).
Items accept the same optional fields as checkout items: performance_id for
multi-date events and seat_ids for numbered seating.
Errors
2. Checkout with the hold
The order takes the hold over (ACTIVE → CONVERTED) and the payment window starts. If the
hold ran out before submit you get 409 with code: "HOLD_EXPIRED" (or HOLD_NOT_ACTIVE if it
was already used or released): show “tu reserva venció” and send the buyer back to the picker.
Without hold_id, checkout/start reserves inline at that moment, exactly as it always did.
That path still works for every storefront; it just loses the guarantee between “pick” and “pay”.
Lifecycle
GET /api/checkout/holds/{id} returns the current state (and expires a hold whose window is
over). You rarely need it: the countdown and the checkout response tell you everything.