API reference
What this covers
Every endpoint, what it is for, and how errors come back. Written for a developer. Requires the API Access feature.
Base URL /api/v1. Authenticate with
Authorization: Bearer {key} (link API keys).
Scopes
Every endpoint requires a scope, and the key must carry it. Scopes are chosen when the key
is created. A request with a valid key but the wrong scope returns 403:
{ "message": "Token lacks required ability: reservations:write." }
The fifteen scopes are reservations:read, reservations:write,
guests:read, guests:write, tables:read, tables:write,
availability:read, menus:read, menus:write,
orders:read, orders:write, reports:read, summons:read, summons:write,
events:read, events:write, waitlist:read, waitlist:write,
webhooks:read and webhooks:write.
Scopes are fixed when a key is created. A key made before a scope existed does not gain it — create a new key to use the newer endpoints.
Discovery
| Endpoint | Purpose | Auth |
|---|---|---|
GET /meta | Version, base URL, auth hint, docs link | None |
GET /ping | Confirms the key and names the restaurant and plan | Key |
Reservations
| Endpoint | Purpose | Scope |
|---|---|---|
GET /reservations | List, filtered | reservations:read |
GET /reservations/export | The same filters as a CSV download | reservations:read |
POST /reservations/{id}/cancel | Cancel, with an optional reason | reservations:write |
POST /reservations/bulk/confirm, /bulk/cancel | Confirm or cancel up to 100 at once | reservations:write |
POST /reservations | Create | reservations:write |
GET /reservations/{id} | One booking | reservations:read |
PATCH /reservations/{id} | Update | reservations:write |
DELETE /reservations/{id} | Cancel | reservations:write |
POST /reservations/{id}/confirm | Status transition | reservations:write |
POST /reservations/{id}/arrive | Status transition | reservations:write |
POST /reservations/{id}/seat | Status transition | reservations:write |
POST /reservations/{id}/complete | Status transition | reservations:write |
POST /reservations/{id}/no-show | Status transition | reservations:write |
POST /reservations/{id}/tables | Assign or change tables | reservations:write |
DELETE /reservations/{id}/tables | Move back to unassigned | reservations:write |
POST /reservations/auto-assign | Best-fit tables for a whole day | reservations:write |
The five transition endpoints are the clean way to move a booking through service — preferable to patching
status directly, because they apply the same rules the app does (link
The reservation lifecycle).
Tables and the unassigned list
Add ?unassigned=1 to GET /reservations to list live bookings that have no table yet — the same set as the Unassigned button on the timeline. It combines with the date and status filters.
POST /reservations/{id}/tables takes table_ids (one table, or up to six to merge) and an optional primary_table_id (which must be one of them). It replaces whatever the booking had before. DELETE /reservations/{id}/tables returns it to the unassigned list and is safe to repeat.
POST /reservations/auto-assign takes a date and seats every unassigned booking that day where one table fits, largest party first. It returns {"data": {"assigned": 3, "unplaceable": [412]}} — the ids in unplaceable fit no single table and need a manual merge.
Two errors to expect. A table that is taken at that time returns 409 with a generic message — it never says who has it. A booking that is already completed, cancelled or a no-show returns 422: tables cannot be changed on it. Changing tables sends a single reservation.updated.
Creating a booking
Either attach an existing guest with guest_id, or supply
guest_first_name and guest_last_name and one will be created. Optional guest fields:
guest_email, guest_phone, guest_vip_status,
guest_tags (array), guest_notes. Plus the booking itself — date, time, pax,
duration, and optionally table_id.
POST /api/v1/reservations
{
"guest_first_name": "Ana",
"guest_last_name": "Costa",
"guest_phone": "+35799123456",
"reservation_date": "2026-10-02",
"reservation_time": "19:30",
"party_size": 4
}
Two things to expect. Availability rules apply — the same operating hours, durations, pacing and capacity checks as the app, so a create can be refused for a reason that is not a validation error (link Why a time or table isn't offered). And the monthly booking limit applies, returning a clear message naming the limit when reached.
Guests
| Endpoint | Purpose | Scope |
|---|---|---|
GET /guests | List and search | guests:read |
GET /guests/export | The same filters as a CSV download | guests:read |
GET /guests/lookup | Exact match by phone or email | guests:read |
GET /guests/{id}/stats | Visits, no-shows and spend | guests:read |
DELETE /guests/{id} | Remove a guest (history is kept) | guests:write |
POST /guests/{id}/anonymize | Erase a guest's personal data | guests:write |
POST /guests | Create | guests:write |
GET /guests/{id} | One guest | guests:read |
PATCH /guests/{id} | Update | guests:write |
There is no delete. Note that some sensitive fields are only returned to a key that also holds
guests:write — a read-only key gets a reduced view, deliberately.
Tables and availability
| Endpoint | Purpose | Scope |
|---|---|---|
GET /tables | List tables. Read-only | tables:read |
GET /tables/{id} | One table | tables:read |
GET /tables/status | The live floor: every table with its status right now | tables:read |
POST /tables/{id}/deactivate, /activate | Take a table out of service, or put it back | tables:write |
GET /dashboard/stats | Today's reservations by status, covers and free tables | reservations:read |
GET /availability/slots | Bookable times for a date and pax | availability:read |
GET /availability/tables | Which tables are free at a time | availability:read |
Always call /availability/slots before offering times on your own form. It
applies every rule the restaurant has configured, so you never show a time that will be refused.
Menus
| Endpoint | Purpose | Scope |
|---|---|---|
GET /menus | List | menus:read |
POST /menus | Create | menus:write |
GET /menus/{id} | One menu with its contents | menus:read |
PUT /menus/{id} | Replace | menus:write |
DELETE /menus/{id} | Delete | menus:write |
GET /menu-items?external_id= | Find items by your own id | menus:read |
PATCH /menu-items/{id} | Change one item (availability, price, name) | menus:write |
PATCH /menu-items/external/{externalId} | Change every item with that external id | menus:write |
Note update is PUT, not PATCH — it replaces rather than merges. Useful for
pushing a menu from a POS as the source of truth (link Your menu).
Your own ids: external_id
Every level of the menu — the menu, each category, item, option group and option — accepts an
optional external_id (text, up to 100 characters): the id your POS or other system uses for it.
It is returned everywhere the menu is returned, and you can look a menu up with
GET /menus?external_id=. There are no rules on the value: it is not required, it
need not be unique, and the same id may appear in several places (one POS item sold in two categories, say).
Syncing without storing our ids. When you send a menu with PUT /menus/{id},
each row is matched to what is already there by our id first, then by your
external_id — always among the rows under the same parent (an item is matched
within its category, an option within its option group, and so on). A match is updated, not duplicated, so
sending the same menu again changes nothing. If several rows under one parent share an external id, the oldest
is used; a row with no external_id (or an empty one) never matches and is created. Leave
external_id out to keep the stored value; send null to clear it.
Remember PUT removes whatever your payload does not mention (send
"delete_missing": false to keep it). Removing a menu item does not remove the orders
that were placed for it — those keep their lines (see Orders below).
Marking an item unavailable without re-sending the menu
PATCH /menu-items/{id} changes is_available, price,
name or external_id on one item. If you only know your own id, use
PATCH /menu-items/external/{externalId}: it applies the change to every item with
that external id (“sold out everywhere”) and answers 404 if none matches.
GET /menu-items?external_id= lists the matching items with their category and menu external ids.
Event bookings
| Event | Fires when |
|---|---|
event_booking.created | A booking is made — by a guest on the public page, by staff, or through the API |
event_booking.updated | Party size, notes, table, date or guest change |
event_booking.arrived | The guest is marked arrived |
event_booking.seated | The guest is seated |
event_booking.completed | The booking is completed |
event_booking.no_show | Marked a no-show |
event_booking.cancelled | Cancelled, by staff, guest or the API |
The payload is the booking as GET /event-bookings/{id} returns it: status, party size, the guest, the table (for seated events), the date (slot) and the event (event). event_booking.updated carries a changes object with old and new values, like reservations do. A status change sends its own event (for example event_booking.arrived) and not an update on top. Cancelling an entire event is not announced per booking yet.
Orders
| Endpoint | Purpose | Scope |
|---|---|---|
GET /orders | List | orders:read |
GET /orders/{id} | One order with its items | orders:read |
POST /orders | Place an order for a table | orders:write |
PATCH /orders/{id} | Edit an open order's notes and existing lines | orders:write |
PATCH /orders/{id}/status | Advance the whole order | orders:write |
PATCH /orders/{id}/items/{itemId}/status | Advance one item | orders:write |
Order and item status stay in step both ways, exactly as on the Orders board — so a kitchen screen can work whole-ticket or item-by-item (link The Orders board).
POST /orders places an order the way a guest scanning the QR code would. Send table_id, an optional notes and items: each with menu_item_id, quantity, an optional notes and option_ids (the ids of the chosen options). Options are checked against the item's option groups (each group's minimum and maximum, and that they belong to that item), the price includes the option surcharges, and an item that is marked unavailable is refused with 422. It uses the table's open ordering session or starts one, notifies staff like a guest order, and fires order.placed.
PATCH /orders/{id} works only while the order is open. Send notes and/or items — each item is { "id": <line id>, "quantity": n, "notes": "..." }, and a quantity of 0 removes the line. Only lines that are still pending can change, and an order can't be emptied (cancel it instead); anything else returns 422. It doesn't add new dishes — place a new order for that. It fires order.updated, whose source is api so you can ignore your own edit.
What each order line carries
Every line in items has menu_item_id and menu_item_name, plus
menu_external_id, category_external_id and item_external_id
— your ids for the menu, category and item it was ordered from. Each entry in its
selected_options has option_external_id and option_group_external_id.
These are recorded when the order is placed, so an order keeps reading correctly after the
item is renamed, re-mapped or removed from the menu. A line whose item has since been deleted has a null
menu_item_id but keeps its name and external ids. Orders placed before external ids existed show the
ids from the current menu instead.
Cancelling and bulk actions
POST /reservations/{id}/cancel takes an optional cancellation_reason; DELETE /reservations/{id} is the same without a body. POST /reservations/bulk/confirm and /bulk/cancel take reservation_ids (up to 100) and return which were processed and which were skipped with the reason — only pending reservations are confirmed, only pending or confirmed ones are cancelled, and a missing id is skipped rather than failing the request. Each reservation fires its own webhook. Whether the guest is messaged follows the Notify guests for API-created reservations toggle on Settings → Notifications (off by default); add notify_guests: true or false on the request to override it either way, for that one call. This also applies to POST /reservations (only once a reservation is confirmed — a pending one has nothing to confirm yet) and POST /reservations/{id}/confirm. POST /reservations accepts is_walk_in: true for a guest already at the door: it skips the advance-notice rule and records the booking source as walk_in.
Guest tools
GET /guests/lookup?phone= or ?email= is an exact match (spaces, dashes and brackets in a phone number are ignored; email is case-insensitive), for caller-ID and CRM de-duplication. Guests aren't unique by phone or email, so it returns a list, usually with zero or one. GET /guests/{id}/stats gives total, completed, no-show, cancelled and upcoming reservations, spend, tips and the last visit. DELETE /guests/{id} removes a guest from the lists (their reservations stay); POST /guests/{id}/anonymize also scrubs every piece of personal data. Both return 409 while the guest has an upcoming reservation, event booking or a waiting waitlist entry. Each sends guest.deleted.
Table sessions
A table session is the period a table can order through its QR code. GET /table-sessions lists them (status of open, the default, closed or all; table_id), GET /table-sessions/{id} adds the session's orders, and POST /table-sessions/{id}/close ends one (the guest's page is told; 409 if it is already closed) and sends table_session.closed. Reading needs orders:read, closing orders:write. The session's secret token is never returned.
Reports
GET /reports/reservations, /guests, /revenue, /utilization and /booking-page (scope reports:read) return the data behind each report page as JSON, produced by the same code, so the numbers always match what the app shows. Give a date range with start_date and end_date (default: the last 30 days). Each needs the same plan feature as its page (Advanced analytics & reports, or Public Booking Page for /booking-page) and returns 403 otherwise. The shape follows the page, so expect fields to be added over time.
Live floor
GET /tables/status (tables:read) returns every active table with what is happening at it right now — the same data as the Live Table Status page. Each table has a status of available, reserved (a booking due soon), delayed (a booking is late), occupied or event, and status_details describing the booking or event behind it (guest, party size, minutes seated or late) plus next_reservation. Narrow it with section or status (comma-separated). meta gives the counts and the time it was calculated. Pair it with the reservation and waitlist webhooks: read the floor once, then refresh when an event arrives.
Summons
GET /summons (summons:read) returns the open signals from guests at tables: waiter_summoned (called a waiter) and bill_requested (asked for the bill). Filter with table_id or event_type.
POST /summons/{id}/clear marks one as handled and POST /summons/clear-all clears every open one, or only one table's with table_id (both need summons:write). Each one cleared sends a summon.cleared webhook, whether it was cleared through the API or by staff in the app. Clearing one that is already cleared returns 422 (link Call waiter and request bill).
Waitlist
| Endpoint | Purpose | Scope |
|---|---|---|
GET /waitlist | The queue, oldest first | waitlist:read |
GET /waitlist/{id} | One entry | waitlist:read |
POST /waitlist | Add a guest to the waitlist | waitlist:write |
PATCH /waitlist/{id} | Change pax, date, time, duration or notes | waitlist:write |
POST /waitlist/{id}/notify | Tell them their table is ready | waitlist:write |
POST /waitlist/{id}/convert | Seat them — creates the booking | waitlist:write |
POST /waitlist/{id}/cancel | Cancel the entry | waitlist:write |
POST /waitlist/{id}/no-show | Mark a no-show | waitlist:write |
DELETE /waitlist/{id} | Delete the entry | waitlist:write |
GET /waitlist/quick-seat | Who can be seated right now, with the free tables | waitlist:read |
GET /waitlist defaults to today. Narrow it with date, or date_from and date_to, and with status (one or more of waiting, converted, cancelled, no_show, comma-separated).
The guest must already exist. POST /waitlist takes a guest_id (create the guest first with POST /guests), plus party_size, reservation_date, reservation_time and optionally duration_minutes (default 120) and notes. A blacklisted guest is refused.
Only entries that are still waiting can change. Editing, notifying, converting, cancelling or marking a no-show on an entry that is already converted, cancelled or a no-show returns 422.
POST /waitlist/{id}/convert takes a table_id, creates a confirmed booking on it and returns that reservation (201) — the same shape as GET /reservations/{id}. If the table is taken it returns 409 with a generic message that never says who has it. POST /waitlist/{id}/notify sends the same “table is ready” message staff would, if you have those messages switched on, and can be repeated.
Every change fires the matching webhook (link Webhook event reference). Converting fires both waitlist.converted and reservation.created.
Events and event bookings
Requires the Events feature. Read with events:read, book and manage with events:write.
| Endpoint | Purpose | Scope |
|---|---|---|
GET /events | List events (filter by status, type, updated_since) | events:read |
GET /events/{id} | One event with its next dates | events:read |
GET /events/{id}/slots | The event's dates with spots_left, is_sold_out and is_bookable | events:read |
POST /events | Create an event (as a draft) | events:write |
PATCH /events/{id} | Change an event — send only what changes | events:write |
POST /events/{id}/publish | Put a draft live | events:write |
POST /events/{id}/cancel | Cancel the event and every booking | events:write |
POST /events/{id}/slots/{slotId}/cancel | Cancel one date | events:write |
DELETE /events/{id} | Delete an event that has no bookings | events:write |
GET /events/{id}/conflicts | Reservations that clash with the event's tables | events:read |
GET /events/{id}/conflicts/{conflictId}/tables | Tables a clashing reservation could move to | events:read |
POST /events/{id}/conflicts/{conflictId}/resolve | Include it, move it, or cancel it | events:write |
POST /events/{id}/slots/{slotId}/bookings | Book a date | events:write |
GET /event-bookings | List bookings | events:read |
GET /event-bookings/{id} | One booking | events:read |
POST /event-bookings/{id}/arrive, /seat, /complete, /no-show, /cancel | Move the booking along | events:write |
GET /event-bookings defaults to bookings for events happening today. Narrow it with event_id, slot_id, guest_id, date or date_from/date_to, status (comma-separated) and updated_since.
The guest must already exist — create them with POST /guests and pass guest_id, plus party_size and optionally notes. For a seated event you may pass a table_id; otherwise the smallest free table that fits is assigned. A party size outside the event's range, a blocked guest or an event that is not open returns 422; a sold-out date or a table that is taken returns 409 (the message never says who has the table). A booking can be marked arrived or no-show only from confirmed, seated from confirmed or arrived, completed from seated or arrived, and cancelled from confirmed — anything else returns 422. Cancelling gives a communal date its spots back and sends the guest the same cancellation message staff would. Each change fires the matching event_booking.* webhook (link Webhook event reference).
Managing events. POST /events takes the same fields as the staff form (title, type of communal or seated, min_pax, max_pax, capacity for communal events, price, table_selection_type of all, area or specific with table_selection_ids, is_recurring with recurrence_days (0 = Sunday to 6 = Saturday), recurrence_start_date and recurrence_end_date, start_time and end_time as HH:MM, and an optional booking cut-off) and always creates a draft. PATCH /events/{id} changes only what you send; the result is checked as a whole, and a cancelled event can't be edited. POST /events/{id}/publish creates the upcoming dates and puts the event live. If existing reservations clash with the event's tables it returns 409 and the event stays a draft — those clashes are resolved in the app. POST /events/{id}/cancel cancels every booking, sends each guest the cancellation message and releases the tables; it can't be undone. POST /events/{id}/slots/{slotId}/cancel does the same for a single date. DELETE /events/{id} works only when nothing is booked (422 otherwise): cancel the event instead, so the guests are told and the history is kept. Event images are set in the app.
Resolving clashes. When publishing returns 409, GET /events/{id}/conflicts?unresolved=1 lists the reservations in the way. Resolve each with POST .../resolve and an action: include (the reservation stays and counts as part of the event), change_table with a new_table_id from the /tables endpoint, or cancel with an optional reason (the guest is told). A conflict can be resolved once; publish again when none are left.
Webhooks
| Endpoint | Purpose | Scope |
|---|---|---|
GET /webhooks | List endpoints | webhooks:read |
GET /webhooks/events | Every event you can subscribe to, with its delivery tier | webhooks:read |
POST /webhooks/{id}/rotate-secret | Issue a new signing secret (shown once) | webhooks:write |
GET /webhooks/{id} | One endpoint | webhooks:read |
POST /webhooks | Register one | webhooks:write |
PATCH /webhooks/{id} | Update | webhooks:write |
DELETE /webhooks/{id} | Remove | webhooks:write |
GET /webhooks/{id}/deliveries | Delivery history | webhooks:read |
POST /webhooks/{id}/test | Send a test payload | webhooks:write |
POST /webhooks/{id}/deliveries/{delivery}/retry | Re-send a failed delivery | webhooks:write |
These need the Webhooks feature as well as API Access. Without it they return
403 naming the feature (link Webhooks).
Health. Every webhook carries a health object: status
(healthy, failing or paused), consecutive_failures,
last_success_at, last_failure_at, last_failure_reason, and for a paused
webhook paused_at, pause_reason and resume_at. A webhook is paused
automatically after 10 events in a row fail every attempt, and restarts by itself an hour later; set
is_active to true to restart it now. One you switched off yourself is
inactive but not paused, and never restarts on its own.
Retrying a delivery. POST /webhooks/{id}/deliveries/{delivery}/retry re-sends a
failed delivery with the same delivery_uuid (the value of
X-Tabledoo-Delivery-Id) and answers 202. A delivery that is not failed, or a webhook
that is switched off, returns 422. GET /webhooks/{id}/deliveries lists deliveries with
their delivery_uuid and accepts ?status=failed.
Catching up after an outage
Webhook retries cover minutes to an hour. To re-sync after a longer gap, add
?updated_since=2026-10-02T18:00:00%2B03:00 (any ISO-8601 time) to GET /reservations,
/guests, /orders or /waitlist. You get everything that changed at or after
that moment, oldest change first, from any date — the usual “today only”
default of reservations and the waitlist is skipped. Page through with page, remember the last
updated_at you saw, and use it next time. A change to an order’s items counts as a change to the
order.
Deleted records are not listed, so keep the *.deleted webhooks for those.
Errors
| Code | Means |
|---|---|
401 | Missing, malformed, expired or revoked key |
403 | The key lacks the scope, or your plan lacks the feature. The message says which |
404 | No such record in this restaurant |
422 | Validation failed, or a business rule refused it — e.g. no availability, or the monthly booking limit |
429 | Rate limited. Back off and retry (link API keys) |
Validation errors
Standard Laravel shape — a message plus a field-keyed errors object:
{
"message": "The given data was invalid.",
"errors": {
"reservation_date": ["The reservation date field is required."],
"party_size": ["The party size must be at least 1."]
}
}
Key on the field names, not the message text, which may be translated or reworded.
Good to know
A 422 is not always a bad request. "That time is no longer available" arrives as a 422 too — your integration should treat it as a real answer and re-check availability, not as a bug.
Writes fire webhooks and send guest messages. A booking created by API is a booking (link Webhook event reference).
Changes are recorded. API activity appears in the same history as staff changes.