API reference

Available in the Enterprise and Pro plans. View plans

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

EndpointPurposeAuth
GET /metaVersion, base URL, auth hint, docs linkNone
GET /pingConfirms the key and names the restaurant and planKey

Reservations

EndpointPurposeScope
GET /reservationsList, filteredreservations:read
GET /reservations/exportThe same filters as a CSV downloadreservations:read
POST /reservations/{id}/cancelCancel, with an optional reasonreservations:write
POST /reservations/bulk/confirm, /bulk/cancelConfirm or cancel up to 100 at oncereservations:write
POST /reservationsCreatereservations:write
GET /reservations/{id}One bookingreservations:read
PATCH /reservations/{id}Updatereservations:write
DELETE /reservations/{id}Cancelreservations:write
POST /reservations/{id}/confirmStatus transitionreservations:write
POST /reservations/{id}/arriveStatus transitionreservations:write
POST /reservations/{id}/seatStatus transitionreservations:write
POST /reservations/{id}/completeStatus transitionreservations:write
POST /reservations/{id}/no-showStatus transitionreservations:write
POST /reservations/{id}/tablesAssign or change tablesreservations:write
DELETE /reservations/{id}/tablesMove back to unassignedreservations:write
POST /reservations/auto-assignBest-fit tables for a whole dayreservations: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

EndpointPurposeScope
GET /guestsList and searchguests:read
GET /guests/exportThe same filters as a CSV downloadguests:read
GET /guests/lookupExact match by phone or emailguests:read
GET /guests/{id}/statsVisits, no-shows and spendguests:read
DELETE /guests/{id}Remove a guest (history is kept)guests:write
POST /guests/{id}/anonymizeErase a guest's personal dataguests:write
POST /guestsCreateguests:write
GET /guests/{id}One guestguests:read
PATCH /guests/{id}Updateguests: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

EndpointPurposeScope
GET /tablesList tables. Read-onlytables:read
GET /tables/{id}One tabletables:read
GET /tables/statusThe live floor: every table with its status right nowtables:read
POST /tables/{id}/deactivate, /activateTake a table out of service, or put it backtables:write
GET /dashboard/statsToday's reservations by status, covers and free tablesreservations:read
GET /availability/slotsBookable times for a date and paxavailability:read
GET /availability/tablesWhich tables are free at a timeavailability: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

EndpointPurposeScope
GET /menusListmenus:read
POST /menusCreatemenus:write
GET /menus/{id}One menu with its contentsmenus:read
PUT /menus/{id}Replacemenus:write
DELETE /menus/{id}Deletemenus:write
GET /menu-items?external_id=Find items by your own idmenus:read
PATCH /menu-items/{id}Change one item (availability, price, name)menus:write
PATCH /menu-items/external/{externalId}Change every item with that external idmenus: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

EventFires when
event_booking.createdA booking is made — by a guest on the public page, by staff, or through the API
event_booking.updatedParty size, notes, table, date or guest change
event_booking.arrivedThe guest is marked arrived
event_booking.seatedThe guest is seated
event_booking.completedThe booking is completed
event_booking.no_showMarked a no-show
event_booking.cancelledCancelled, 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

EndpointPurposeScope
GET /ordersListorders:read
GET /orders/{id}One order with its itemsorders:read
POST /ordersPlace an order for a tableorders:write
PATCH /orders/{id}Edit an open order's notes and existing linesorders:write
PATCH /orders/{id}/statusAdvance the whole orderorders:write
PATCH /orders/{id}/items/{itemId}/statusAdvance one itemorders: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

EndpointPurposeScope
GET /waitlistThe queue, oldest firstwaitlist:read
GET /waitlist/{id}One entrywaitlist:read
POST /waitlistAdd a guest to the waitlistwaitlist:write
PATCH /waitlist/{id}Change pax, date, time, duration or noteswaitlist:write
POST /waitlist/{id}/notifyTell them their table is readywaitlist:write
POST /waitlist/{id}/convertSeat them — creates the bookingwaitlist:write
POST /waitlist/{id}/cancelCancel the entrywaitlist:write
POST /waitlist/{id}/no-showMark a no-showwaitlist:write
DELETE /waitlist/{id}Delete the entrywaitlist:write
GET /waitlist/quick-seatWho can be seated right now, with the free tableswaitlist: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.

EndpointPurposeScope
GET /eventsList events (filter by status, type, updated_since)events:read
GET /events/{id}One event with its next datesevents:read
GET /events/{id}/slotsThe event's dates with spots_left, is_sold_out and is_bookableevents:read
POST /eventsCreate an event (as a draft)events:write
PATCH /events/{id}Change an event — send only what changesevents:write
POST /events/{id}/publishPut a draft liveevents:write
POST /events/{id}/cancelCancel the event and every bookingevents:write
POST /events/{id}/slots/{slotId}/cancelCancel one dateevents:write
DELETE /events/{id}Delete an event that has no bookingsevents:write
GET /events/{id}/conflictsReservations that clash with the event's tablesevents:read
GET /events/{id}/conflicts/{conflictId}/tablesTables a clashing reservation could move toevents:read
POST /events/{id}/conflicts/{conflictId}/resolveInclude it, move it, or cancel itevents:write
POST /events/{id}/slots/{slotId}/bookingsBook a dateevents:write
GET /event-bookingsList bookingsevents:read
GET /event-bookings/{id}One bookingevents:read
POST /event-bookings/{id}/arrive, /seat, /complete, /no-show, /cancelMove the booking alongevents: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

EndpointPurposeScope
GET /webhooksList endpointswebhooks:read
GET /webhooks/eventsEvery event you can subscribe to, with its delivery tierwebhooks:read
POST /webhooks/{id}/rotate-secretIssue a new signing secret (shown once)webhooks:write
GET /webhooks/{id}One endpointwebhooks:read
POST /webhooksRegister onewebhooks:write
PATCH /webhooks/{id}Updatewebhooks:write
DELETE /webhooks/{id}Removewebhooks:write
GET /webhooks/{id}/deliveriesDelivery historywebhooks:read
POST /webhooks/{id}/testSend a test payloadwebhooks:write
POST /webhooks/{id}/deliveries/{delivery}/retryRe-send a failed deliverywebhooks: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

CodeMeans
401Missing, malformed, expired or revoked key
403The key lacks the scope, or your plan lacks the feature. The message says which
404No such record in this restaurant
422Validation failed, or a business rule refused it — e.g. no availability, or the monthly booking limit
429Rate 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.

Where to go next