Skip to content

Table Reservations

Guests book a table on the restaurant's own site, and the front of house runs the evening from the Diary in admin (or the Badger app). Bookings are held against real tables, so the last table can't be sold twice, and every guest who leaves an email or phone number gets a light profile the staff can use, with GDPR rules built in.

In development, off by default

Table bookings are switched on per shop with Settings › Dining in › Reservations enabled (reservations-enabled). Table service (tabs, courses, paying at the table) is Table service. How to set it up step by step is in Table bookings. The same booking engine takes appointments with a team of practitioners: Appointments.

The pieces

Piece What it is
Location capability DINE_IN "Seats diners" on the location form. Only these locations can have a dining room.
Dining room (DiningRoom) One per location: areas, tables, combinations, services, pacing and the booking policy. Edited and saved whole, with optimistic locking.
Booking (Reservation) A party, a time range and the tables it holds. Its own document, not an order or a ticket.
Table holds (TableHold) One row per table per quarter hour, keyed so the database refuses a double booking. A worker job puts them right from the bookings every 30 minutes.
Guest profile (Guest) Per shop, matched by email then phone: visits, no-shows, staff tags and notes, consents.

The dining room

Areas group tables ("Dining room", "Terrace"). A guest may ask for an area, which then limits the tables offered. An area can be walk-ins only (never booked online) or kept off online booking.

Tables seat from their fewest to their most guests (minCovers-maxCovers), may be held back from online booking, and carry a rank: when several tables fit, the lower rank sells first.

Combinations are explicit sets of tables pushed together, each with its own party range (a 4-top and a 2-top that take 5 or 6, not 3). Single tables are always tried before combinations; then the smallest that fits, then the venue's order. The editor lists combinations in that order and the arrows change it.

Services are the sittings, such as Lunch and Dinner. Each has:

  • the days it runs (none ticked means every day) and optional seasonal dates;
  • the first booking time, the last booking time and the closing time (a closing time earlier than the first booking means after midnight);
  • the interval bookings start on (15, 30 or 60 minutes);
  • how long a table is kept: a usual stay, overridden by party size ("90 minutes for 1-2, 120 for 3-4"), plus a reset time for clearing the table;
  • pacing: the most covers and/or bookings that may arrive from online bookings in one interval, with per-interval changes (0 closes an interval online);
  • an online cover cap for the whole service, holding the rest back for phone bookings and walk-ins.

A service is refused if it closes before its last booking's longest stay ends.

Booking rules (ReservationPolicy): the largest party that books instantly online (bigger parties send an enquiry), the largest party that may book online at all, how many days ahead guests can book, how close to the time online booking closes, the no-show grace period and the late cancellation window.

Which times are offered

A time is offered when the location is open for it (special days and temporary closures from the location cut the services; weekly opening hours don't, as they are often the takeaway's hours), a table or combination that fits the party is free for the whole stay plus the reset time, and, for an online booking, the interval's pacing and the service's online cap have room. Staff bookings and walk-ins ignore pacing, the caps, the cut-off and the online flags.

Pacing is counted from the bookings themselves, so two guests taking the last pacing room in the same second may both get in. Tables are not: booking writes the holds first, and a guest who loses the race for the last table is offered the nearest other times.

The booking tester

The dining room editor has a Test a booking panel: a date, a party size, and online or staff. It shows every time that day with the tables it would take, and for each refused time the reason: the online cut-off, outside special hours, temporarily closed, no table seats the party, every fitting table taken, pacing full ("Pacing: 14 of 16 covers already arrive at 19:00"), or the online cap reached. It tests the room as it stands in the editor, so a change can be tried before it is saved. Underneath it is TableAvailabilityService.explain, which the MCP diningRoom test action uses too.

Booking on the shop

The table booking block

Add the Book a table block (tableBooking) to any page, or link to /book, which shows the same form on its own. The block's settings are an optional location (otherwise the guest picks from every location taking bookings), a heading and a short introduction.

The guest picks the party size and the date (and the location or area when there's a choice), then one of that day's free times, shown by service. A full day offers the next days with a table. Then come their name, email, an optional phone, the occasion, a request, dietary needs (kept for this visit only, unless they tick the Keep my allergies on file for future visits box beneath, the explicit allergy consent, which starts unticked), the consent wording and an unticked box for news and offers. Bigger parties than the policy's instant-book size send an enquiry instead, which staff approve or decline in the diary.

A shopper who is signed in to their account sees their first name, last name, email and phone filled in from the details saved on it, and can change any of them for this booking. Visitors who aren't signed in always get an empty form, even on a device that has booked before, so one guest's details never appear for the next.

The form works without JavaScript: the choice is a plain GET form that reloads with that date's times, and the booking posts to /book, which comes back with what was typed if something is wrong. With JavaScript, the script asks the public API for times as the choice changes and books through it; if the chosen time has just gone, it redraws the day and offers the nearest free times, or an enquiry. Retrying a booking (a double tap, a lost connection) returns the same booking rather than a second one.

The block is styled by --tb-* tokens a theme can set (Bistro does). On the legacy Bootstrap theme the block and /book give each location's phone number instead of the form.

The guest's booking page

Every booking email carries a personal link to /booking/{token}. The token is signed for this shop and this purpose, so no sign-in is needed and it opens nothing else. The page shows the booking (never the guest's name, email or phone, since the link may be forwarded) and lets the guest:

  • change the time, party size or request, up to the policy's online cut-off;
  • cancel, with the terms first (a late cancellation says so);
  • confirm they're still coming: the reminder links to ?confirm=1, which only asks, because mail scanners open links; the button confirms;
  • add to calendar: an .ics file (the same event the emails attach, so a change or cancellation moves or removes it) or Google Calendar.

The page is private, kept out of search engines and sent without a referrer. On the Bootstrap theme it is a shorter page: confirm, cancel and add to calendar, and a phone number for changes.

In the customer's account

A booking made while signed in to the shop shows in the customer's account, on a Bookings tab (/p/account/bookings, accountBookings): what's coming up, soonest first, then past visits. Each row links to the booking's own page above, which changes or cancels it under the same rules; nothing is changed from the account itself.

  • Linking. Booking while signed in links the booking's guest profile to the account (GuestService.linkToUser), once: a profile already linked to one account is never moved to another, except when accounts are merged. Profiles are never linked by email or phone alone, since the shop doesn't verify an account's email, so signing up with someone else's address shows none of their bookings. A customer who books with two emails has two profiles, and the tab lists both.
  • What shows. The booking as its guest sees it (GuestBookingViews.GuestBooking): when, where, the party size, the reference and its status. Staff tags, notes and visit or no-show counts never reach the page.
  • The tab appears when the shop takes bookings (reservations-enabled), or when the customer has bookings, so turning bookings off hides no one's history.

The public booking API

The block and the booking page use a public API under /api/public/v1/reservations (tag Reservations in the API reference). It takes no token: the shop is the request's host, and the booking routes use the link's token. Headless storefronts can use it the same way.

Call What it does
GET /options The locations taking bookings, with their rules: party sizes, how far ahead, the cut-off, areas, the policy in words
GET /days Which days in a range have a free time for a party, for a calendar
GET /availability A date's times for a party, by service; with near, the nearest free times to that time
POST / Book. 201 booked, 202 sent as an enquiry (too big to book instantly), 409 taken (with nearest)
POST /enquiries Ask for a table rather than book it (202): staff approve or decline it
GET / PATCH /manage/{token} The guest's booking; change its time, party size or request
POST /manage/{token}/confirm Confirm from the reminder (again returns it as it is)
GET /manage/{token}/cancel-terms, POST /manage/{token}/cancel What cancelling now would mean, then cancel
  • Idempotency: send an Idempotency-Key header when booking. A retry with the same key returns the booking already made.
  • Errors: 400 and 404 answer in plain text; 409 is JSON with a message and the nearest free times, in the restaurant's own offset (19:30+01:00).
  • Privacy: no answer carries the guest's name, email or phone, and every answer is Cache-Control: private, no-store.
  • Limits: per address and shop, 60 lookups and 10 changes (book, enquire, change, confirm, cancel) a minute, counted in Redis under a hash of the address (never the address itself), with a 429 and Retry-After beyond them. The storefront's own forms share the same limits.

Front of house uses a separate, signed-in API (/v1/dining, tag Dining): see Mobile POS.

A booking's life

ENQUIRY → BOOKED (approved by staff) | DECLINED
BOOKED → CONFIRMED (the guest confirmed from the reminder)
BOOKED|CONFIRMED → ARRIVED → SEATED → FINISHED
BOOKED|CONFIRMED → NO_SHOW (marked by staff after the grace period; never automatic)
any open state → CANCELLED (by the guest or the venue; "late" inside the policy's window)

A walk-in is a booking made at the door, already seated. It keeps its tables until its stay ends or the tables' next booking starts, whichever is first.

Seating a party before their time holds the tables from now to the booking's own end (the usual stay from now finishes before that, so nothing later is touched). If another booking holds one of those tables in that time, seating is refused and says which and when: Table 10 is still taken until 18:30; seat them at other tables, or Table 10 is booked from 17:45; seat them at other tables.

The diary

Bookings › Diary shows one location's bookings for a day, narrowed to a service if you like:

  • a timeline of tables (by area) against time. Each bar is a booking, led by the party size and coloured by where it is: outlined while booked (dashed once late), amber when arrived, solid green when seated, grey once they've left, hatched red for a no-show. A line marks now, and a row above the tables shows covers arriving in each interval against pacing;
  • a list by arrival time, with the moves to hand (the default on a phone);
  • the enquiries waiting for an answer, with Approve and Decline;
  • the day's covers: booked, still to come, arrived, seated and left.

Clicking a booking opens its details: contact, occasion, request, allergies, staff notes, how many times the guest has visited, and the moves it allows now (arrived, seat, seat at other tables, left, no-show, cancel with a reason). Book by phone asks the server which times have a table for the party; Walk-in shows which tables are free now. Seat at other tables and Approve at chosen tables grey out the tables another booking holds at that booking's time, with a note such as booked from 17:45 or taken until 18:30. The board refreshes every 30 seconds, not while a dialog is open.

Guests and GDPR

A booking is matched to a guest profile by email, then by phone (UK numbers match with or without +44). A walk-in who gives only a name has no profile. The profile counts bookings, visits, no-shows and the guest's own cancellations, and holds staff tags ("regular", "vip" show in the diary), preferences and notes.

Guest history in the Badger app. A booking opened in the app's Diary shows the guest's history at this restaurant: "5th visit · last here 12 Sep" (or "First visit"), a Regular chip (3 or more visits, or tagged regular) and their tags, any no-shows, late cancellations and complaints (refunds given with a reason on their bills), what they usually have (up to three dishes they've ordered at least twice over their last ten visits, drinks left out) and, for managers only, what they spend in total and per visit. The diary and enquiry lists show the short version on each row ("Regular" or "5th visit", "1 no-show"). Spend and dishes come from the bills of tables seated from their bookings, so walk-ins without a booking aren't counted. It is the same personal data as the rest of the booking: never cached by the app, never logged, and gone when the profile is.

  • Allergies are special-category data. A booking always keeps the allergies given for that visit, and they are cleared 30 days after it. They reach the profile only when the guest ticked the explicit consent box, stamped with its date; staff can remove them (and the consent) from the profile.
  • Marketing consent is a separate box, never pre-ticked, stored with its date.
  • Retention: a nightly worker job (GuestRetentionJob) erases profiles not seen for Keep guest details for (months) (dining-guest-retention-months, 24 by default), counting from the latest of their last visit and their last booking. Their bookings are scrubbed of names, contact details, requests, notes and allergies; ids, dates, party sizes and statuses stay for reports. Older bookings with no profile are scrubbed the same way.
  • Download guest data on a guest's page is everything held about them (profile and bookings) as JSON, for a subject access request.
  • Forget this guest does the retention job's work at once, after the guest's name is typed.
  • Bookings and guests never travel in a site transfer, and logs carry ids and references only.

MCP

Three tools: reservations (a location's diary for a date, or one booking by id or reference; needs mcp:write because it returns guest details), diningRoom (get the room, test the booking tester for a date and party, online or as staff) and manageReservations (book as a phone booking with a local startsAt, amend, cancel, arrive, seat, finish, noShow, walkIn, approve, decline, and saveRoom with the whole room, returning the room's problems when it can't be saved).

The demo

The Ember & Oak demo (ember) has a dining room at Headingley: 14 tables in two areas, seven ranked combinations, lunch Friday to Sunday and dinner Tuesday to Sunday, and ten made-up sample bookings for today and tomorrow that the demo seeder makes through the booking service. Its Find us page ends with the Book a table block, as well as the standalone /book page.

Code map

Concern Where
Domain common/.../dining/ (DiningRoom, Reservation, TableHold, Guest)
Availability and the tester TableAvailabilityService, AvailabilityEngine (commerce-core/.../dining/)
Bookings ReservationService
Guests, retention, forget, export GuestService; worker GuestRetentionJob
Storefront TableBookingExtension, TableBookingPanels, GuestBookingViews (commerce-core); TableBookingController and the booking/ and extensions/tableBooking/ templates (web-mvc)
Public API ReservationPublicController (rest/.../api/dining/), RestGuestReservationConverters, ReservationRateLimiter
Calendar files ReservationCalendar (emails and the booking page)
Booking emails ReservationEmailListener queues them on the email-jobs queue (retried, then parked); ReservationEmailComposer builds subject, model and .ics; worker ReservationReminderJob sends the day-before reminder. See transactional email
Admin web-admin/.../render/admin/dining/ (DiaryAdminController, DiaryView, DiningRoomAdminController, GuestAdminController)
MCP ai/.../mcp/tools/DiningTools.java