List bookable table slots
The hospitality availability grid. Expired holds for this org are swept before reading, which makes the system self-healing: an exclusion-constraint predicate cannot call now(), so a dead hold would otherwise keep blocking its table until something deleted it.
Live holds are counted as busy on purpose; a hold is exactly as blocking as a confirmed booking until it expires.
tableId is deliberately NOT returned. It would leak the floor plan, and a client-held allocation goes stale the moment someone else books; the POST re-runs the allocator server-side regardless. GET .../table-options is the one scoped, opt-in exception, for orgs with tablePickerEnabled on.
area_id/level_id (migration 0030) narrow the candidate tables before allocating; passing a level_id INCLUDES every area nested under it, not just tables sitting directly on the level with no room, so a fully-subdivided floor is not a location choice that always resolves to nothing.
Ranged mode (days). Same contract as the appointments twin, and the same reason: the wizard's landing walk used to fire one request per day (up to 14), each paying the full fan-out and a hold-sweep DELETE. The single-date shape ({slots, reason}) is unchanged and stays the default; sending days greater than 1 switches the response to {days: {"<date>": {slots, reason}, ...}}.
Path Parameters
The organization's public booking slug, i.e. the {slug} in /{slug}. Only orgs with status active resolve; anything else is a 404.
Query Parameters
YYYY-MM-DD, strictly. A non-matching string is a 400, not a coerced date. The first day answered in ranged mode.
dateCapped at 30: party size is attacker-controlled and feeds a slot loop.
1 <= value <= 30Narrows to one room. Mutually exclusive with level_id; sending both is a 400.
uuidNarrows to one floor, INCLUSIVE of every room nested under it. Mutually exclusive with area_id; sending both is a 400.
uuidScopes the grid to one published Experience (migration 0207): a dated Experience answers ONLY on its own event date (every other date returns reason: "closed"), an Experience restricted to specific service periods is narrowed to them, and one with its own party bounds is validated against party_size (400 if outside them) and its own capacity_per_sitting, if set, independently of the venue-wide max_covers/maxOnlinePartySize. A 404 if the id does not resolve to a published, non-archived Experience for this org.
uuidHow many consecutive days from date to answer in one call. Omitted or 1 keeps the original single-date {slots, reason} shape; 2 to 14 switches the response to {days: {"<date>": {slots, reason}, ...}}.
1 <= value <= 14Present only when the guest manage page calls this route to pick a new time for an existing booking. See the appointments twin's identical parameter for the full reasoning.
uuidResponse Body
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/book/string/reservations/availability?date=2019-08-24&party_size=1"{ "slots": [ { "startsAt": "2019-08-24T14:15:22Z" } ], "reason": "full"}Join the waitlist (public) POST
What a guest leaves behind when a date came back with **no tables at all** (migration 0044). Creates a `book_waitlist_entries` row with status `open`, which lands on the venue's reservations screen. Until this existed, a fully-booked Friday was a dead end; the widget said "try another day" and the guest left, taking with them the one signal a booking page most wants. **Nothing is held or promised by joining.** A table is offered later by a member pressing a button, and the guest re-books through the ordinary flow; whoever gets there first gets it. Same obligations as every other public write here: `guestRateLimit` on the `write` budget runs FIRST, before the slug is resolved and before the body is parsed, so this cannot be used to probe which slugs exist. A party ABOVE the venue's `maxOnlinePartySize` is refused with `enquiryRequired`; above that line the venue has said it wants to look at the group, and a waitlist entry is a promise that a table might simply be offered.
List which tables are free for one exact slot GET
The scoped, opt-in reversal of the availability route's "no tableId" stance (migration 0030): given a slot the guest already has from the ordinary grid, lists the tables actually free at that instant, with name and seat count, never coordinates, since none exist in this schema. **Returns 404, not 403, when `tablePickerEnabled` is off.** Checked here at request time against the real row, not trusted from a widget's cached config; an admin who just switched it off must not have a stale tab keep listing table names. This is the actual security boundary for the reversal; the widget's own gate on rendering the sub-step is a courtesy on top of it, not the guard itself. An instant with no matching slot (outside every period, no table fits, or inside a closure) is not an error; it returns an empty list, the same graceful floor a filtered-out area produces on the main grid.