Booking
Transactional booking and slots
| Method | Path | What it does |
|---|---|---|
| POST | /api/bookings | Create a booking (transactional, row-level locking) |
| POST | /api/bookings/quote | Price a booking without creating one |
| POST | /api/bookings/{bookingId}/release | Release an abandoned checkout |
| POST | /api/bookings/payment-return | The booking behind a PaymentIntent, for the return from a redirect payment |
| POST | /api/slots/preview | Preview bulk slot creation (no commit) |
| POST | /api/slots | Commit bulk slot creation |
Create a booking (transactional, row-level locking)#
/api/bookingsPublic (session optional)Totals are always recomputed server-side from the room's pricing; a client-supplied amount is ignored. Ownership comes from the session, never from the body. The payment mode is validated against the venue's policy — a mode the venue does not offer is refused. Every booking holds its slot for 15 minutes and returns a clientSecret: the player's service fee (the amount is the quote's platformFeeCents) is charged online in all cases, plus the room price when the mode is online. The booking is only confirmed once the webhook reports the payment succeeded. Discount and gift voucher codes are redeemed inside the booking transaction and returned if the payment never completes. A signed-in booker may also say who is playing, as either a team booking (teamId — one of their own teams — and memberPlayerIds, the player.ids of the members coming, the booker left out) or a player booking (players, other players' public player IDs, and guests, names). The two are mutually exclusive. The booker is always a participant, taken from the session; team members picked are accepted at once, while a player named by ID is only invited and is credited with the outcome once they accept. Everyone named, the booker included, must fit in teamSize. An anonymous body that names anyone is refused. The venue's bookerPhone setting (on the room detail's venue) decides the phone: required refuses a booking without one that looks like a phone number, hidden drops any phone sent, and optional keeps whatever was sent, trimmed, with a blank phone stored as none. The response carries the quote the booking was actually written with — the same breakdown the PaymentIntent was built from — so the payment step never shows an amount taken from an earlier quote. An optional invoice object ("Need an invoice?": buyerKind business or person, name, vatNumber for an EU business, address, postalCode, city, country, email) is stored with the booking, in its transaction, for a Belgian venue only; an EU VAT number is checked with VIES first, and a VIES that does not answer never fails the booking. MTEC Games VOF's service fee invoice is emailed once the booking is confirmed.
Example request
curl -X POST https://myescaperoom.eu/api/bookings \
-H "Content-Type: application/json" \
-d '{"slotId":"slot-123","guestName":"Sofie V.","guestEmail":"sofie@example.com","teamSize":4,"paymentMode":"online"}'Example request body
{
"slotId": "slot-123",
"guestName": "Sofie V.",
"guestEmail": "sofie@example.com",
"phone": "+32 470 00 00 00",
"teamSize": 4,
"paymentMode": "online"
}Responses
| Status | Meaning |
|---|---|
201 | { bookingId, ref, clientSecret, quote } |
400 | Invalid body; or who is playing was refused: PARTICIPANTS_REQUIRE_PLAYER (no signed-in player), NOT_A_TEAM_MEMBER, PLAYER_NOT_FOUND, DUPLICATE_PARTICIPANT, TOO_MANY_PARTICIPANTS; or the venue requires a phone: PHONE_REQUIRED (none sent), PHONE_INVALID (not a phone number) |
409 | Slot unavailable, already full, or already started |
422 | Invalid team size or contact-priced room; or INVOICE_NOT_AVAILABLE (an invoice for a venue outside Belgium) |
502 | Stripe did not respond; the held seats were released |
503 | The venue has no connected Stripe account |
Price a booking without creating one#
/api/bookings/quotePublic (session optional)Read-only: a discount or voucher code checked here is only reported as valid, never spent. The booking endpoint recomputes everything itself from the room's own pricing, so nothing returned from this endpoint is trusted back. This endpoint is unauthenticated, so a voucher code that exists but has no balance left is reported as VOUCHER_INVALID rather than VOUCHER_EMPTY — the distinction would confirm which guessed codes are real. POST /api/bookings keeps it.
Example request
curl -X POST https://myescaperoom.eu/api/bookings/quote \
-H "Content-Type: application/json" \
-d '{"slotId":"slot-123","teamSize":4,"paymentMode":"online","discountCode":"ESCAPE10"}'Example request body
{
"slotId": "slot-123",
"teamSize": 4,
"paymentMode": "online",
"discountCode": "ESCAPE10"
}Responses
| Status | Meaning |
|---|---|
200 | { quote: { roomSubtotalCents, discountCents, voucherCents, platformFeeCents, totalCents, onlineChargeCents, dueAtVenueCents }, allowedPaymentModes } |
404 | Slot or room not found |
409 | The venue is not live, the slot has started, or the slot is taken |
422 | Invalid team size, disallowed payment mode, invalid code, or contact-priced room |
Release an abandoned checkout#
/api/bookings/{bookingId}/releasePublic (session optional)Gives back the hold of a pending booking whose player left checkout, so the slot is free at once rather than when the 15-minute hold runs out, and cancels its PaymentIntent. A guest proves the booking is theirs with the PaymentIntent's clientSecret (from POST /api/bookings): its id must be the booking's PaymentIntent, and the secret must be the one Stripe holds. The signed-in booker may send {} instead. Anything else is a 404, never a 403, so a booking id cannot be probed. Whether the booking is unpaid is asked of Stripe, not read from its status: a payment that went through before its webhook landed counts as paid. Releasing a booking that is already cancelled answers 200 with it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
bookingIdrequired | path | string |
Request body
| Field | Type | Description |
|---|---|---|
clientSecretoptional | string |
Example request body
{
"clientSecret": "pi_123_secret_456"
}Responses
| Status | Meaning |
|---|---|
200 | { booking } — cancelled, now or already |
400 | INVALID_JSON; INVALID_INPUT |
404 | BOOKING_NOT_FOUND — no such booking, or neither the client secret nor the session proves it |
409 | BOOKING_PAID — the payment went through (the booking stands); PAYMENT_PROCESSING — Stripe is still processing it, try again later |
429 | RATE_LIMITED — too many attempts from this address (20 per 10 minutes, shared with POST /api/bookings/payment-return); see Retry-After |
502 | STRIPE_ERROR — Stripe could not be asked whether the booking is paid; nothing was released, the hold runs out by itself |
The booking behind a PaymentIntent, for the return from a redirect payment#
/api/bookings/payment-returnPublic (session optional)Where Bancontact, iDEAL and 3-D Secure redirects send the player back to. The PaymentIntent's clientSecret (from POST /api/bookings) is the proof, so it works without a session and from another device: its id must be a booking's PaymentIntent, and the secret must be the one Stripe holds. Anything else is a 404, never a 403. The secret is sent in the body so it stays out of the API's own access logs. intentStatus is Stripe's status for the payment, which can be succeeded while the booking is still pending until the webhook lands. No guest details are returned. room and venue carry only what the ticket prints and are read without the live filter, so a venue paused since still shows its ticket. cancellation is released for an unpaid hold given back and cancelled for a paid booking cancelled later, null otherwise. roomLive is whether the venue is live, so whether the room page still exists for players. party (the team name, the other players with a kind of member, invited or guest, and the registered players who could be invited to a team of the group) is sent only when the caller's player session is the booking's booker, else null.
Request body
| Field | Type | Description |
|---|---|---|
clientSecretrequired | string |
Example request body
{
"clientSecret": "pi_123_secret_456"
}Responses
| Status | Meaning |
|---|---|
200 | { bookingId, ref, bookingStatus, cancellation, intentStatus, room, roomLive, venue, slot, teamSize, totalCents, onlineChargeCents, dueAtVenueCents, paymentMode, party } |
400 | INVALID_JSON; INVALID_INPUT |
404 | BOOKING_NOT_FOUND — no booking, or the client secret does not prove it |
429 | RATE_LIMITED — too many attempts from this address (20 per 10 minutes, shared with POST /api/bookings/{bookingId}/release); see Retry-After |
502 | STRIPE_ERROR — Stripe could not be asked about the payment |
Preview bulk slot creation (no commit)#
/api/slots/previewSessionRequires the caller to hold at least the admin venue role.
Responses
| Status | Meaning |
|---|---|
200 | Candidate slots + conflicts |
Commit bulk slot creation#
/api/slotsSessionRequires the caller to hold at least the admin venue role.
Responses
| Status | Meaning |
|---|---|
200 | Created + skipped slots |