Modules
Reservations
Opening hours, slots, date overrides and approval. Everything a booking widget needs.
Reservations is the table booking module: your customer sets when they are open and how their evening is divided, and your widget offers exactly those moments. The rules live in one settings object, and the same rules are enforced again on write, so a widget that is slightly out of date cannot book an impossible time.
Reading the rules
/api/reservation-settingsread_reservation_settingsThe resolved opening hours, slot configuration, party size cap and the venue timezone.
| Field | Use |
|---|---|
opening_hours | The resolved schedule that applies to reservations — weekly pattern, exception dates, timezone, and which source it came from. |
time_slot_config | The slot interval, and the first and last slot of the week. |
max_guests | A cap on party size, or null for none. Enforced: a larger party_size is rejected with 400 PARTY_SIZE_TOO_LARGE. It is the only maximum — a max on the type's own "Party Size" field does not apply once party_size is set. |
capacity_config | How many reservations and how many covers fit in one slot. Absent or enabled: false means no limit anywhere. |
enable_reservation_requests | Whether new reservations arrive as a request awaiting approval. |
calendar_timezone | The IANA zone every calendar date and wall clock time resolves against. |
A key gets exactly these six fields and nothing else. The dashboard reads the whole row, which also holds the addresses the venue's staff are notified on — not something a website needs, so a key never sees it.
Which schedule you get
{
"opening_hours": {
"timezone": "Europe/Amsterdam",
"source": "organization",
"weekly": {
"monday": { "enabled": true, "open": "09:00", "close": "17:00" },
"sunday": { "enabled": false, "open": "09:00", "close": "17:00" }
},
"exceptions": [
{ "date": "2026-12-25", "mode": "closed", "label": "Christmas" },
{ "date": "2026-12-28", "mode": "open", "open": "17:00", "close": "21:00" }
]
}
}This is already resolved for you, and that is the whole of what is different here. A venue either follows its organization-wide hours or gives reservations a narrower window of its own; you get whichever applies, and source says which one it was — organization or custom. You never combine the two yourself, and there is no second call to make.
What the fields mean, how a date resolves against weekly and exceptions, and why every time here is wall-clock in the venue timezone rather than UTC are the same for reservations as for anything else the business does. They are written down once, at /docs/organization, and they are worth reading before you build a widget on top of this — the timezone rule in particular is the most common source of an off-by-one-day bug in a booking flow.
Slot configuration
{
"slot_interval_minutes": 30,
"first_slot": "09:00",
"last_slot": "17:00"
}first_slot and last_slot are the widest range across the week, offered as a hint for drawing a picker. They are not the rule: a day may open later or close earlier than either. Generate slots from that day's own resolved hours, and let the server be the judge.
Resolving a date
- 01
A closed exception wins
An entry in
exceptionsfor that date withmode: "closed"means no slots. Stop here. - 02
An open exception replaces the weekday
An entry with
mode: "open"uses its ownopenandclose, and ignores the weekday'senabledflag entirely. That is what makes "normally closed on Sunday, but not this Sunday" expressible. - 03
Otherwise the weekday applies
Fall back to
opening_hours.weeklyfor that date's weekday.
Then generate slots every slot_interval_minutes from the opening time up to but not including the closing time. A venue open 09:00 to 17:00 on a 30 minute interval has its last slot at 16:30.
function slotsForDate(dateKey, settings) {
const { opening_hours, time_slot_config } = settings
const { weekly, exceptions, timezone } = opening_hours
const exception = exceptions.find((e) => e.date === dateKey)
if (exception?.mode === 'closed') return []
const hours = exception?.mode === 'open'
? { enabled: true, open: exception.open, close: exception.close }
: weekly[weekdayOf(dateKey)]
if (!hours?.enabled) return []
const interval = time_slot_config?.slot_interval_minutes ?? 30
const slots = []
let [h, m] = hours.open.split(':').map(Number)
const [ch, cm] = hours.close.split(':').map(Number)
while (h < ch || (h === ch && m < cm)) {
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
m += interval
if (m >= 60) { h += 1; m -= 60 }
}
return slots
}
// The weekday of a calendar date needs no timezone: 2026-08-22 is a Saturday
// everywhere. Converting it into an instant first is how you end up serving
// tomorrow's schedule to anyone far enough east.
function weekdayOf(dateKey) {
const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']
return days[new Date(`${dateKey}T00:00:00.000Z`).getUTCDay()]
}Turn each label into the UTC instant it represents in calendar_timezone before sending it. That conversion is the part worth using a library for: it is where daylight saving quietly moves an evening by an hour.
Capacity per slot
Opening hours say when a venue takes bookings. Capacity says how many fit at once. It is counted in two units, each of which can be left unset: how many reservations, and how many covers. A slot is full as soon as either is reached — three tables of ten is a sold-out evening that counts as three bookings, and twenty couples is the same kitchen load as two tables of ten.
Every organization starts with no limits, and nothing changes until someone switches it on under Settings then Reservations.
{
"version": 1,
"enabled": true,
"default": { "max_reservations": null, "max_covers": 60 },
"weekly": {
"friday": [
{ "from": "17:00", "to": "19:00", "max_reservations": 8, "max_covers": 30 },
{ "from": "19:00", "to": "22:00", "max_reservations": 12, "max_covers": 50 }
]
},
"exceptions": [
{ "date": "2026-12-31", "label": "New Year's Eve",
"default": { "max_reservations": 0, "max_covers": 0 },
"windows": [{ "from": "18:00", "to": "23:00", "max_reservations": 4, "max_covers": 20 }] }
]
}- 01
An exception for that calendar date
It replaces the weekday entirely — the weekly ranges are not consulted on a date that has an exception.
- 02
A time range for that weekday
Ranges are half-open, so 17:00–19:00 does not apply at 19:00 and two ranges can sit back to back without the hour between them counting twice.
- 03
The default
Applies to every slot no rule above reached.
- 04
Nothing
No rule at all means no limit.
Where two ranges overlap, the lowest limit wins for each unit independently. That is deliberate: it means the order of the array never decides what applies, so adding a range or re-saving the list cannot quietly change a venue's rules.
Staff are never blocked by any of this. A host taking a phone call for a full Saturday can add one more table from the dashboard; the response carries an X-Capacity-Warnings header naming what was overrun, and the reservation is created as normal.
Reservation types and their fields
/api/reservation-typesread_reservation_settingsWhich reservation types the venue has, and what each one's form asks for. The same scope as the settings, and the client names it types.
{
"success": true,
"data": [
{
"id": "…",
"slug": "restaurant-table-reservation",
"name": "Table",
"description": null,
"settings": { "default_duration_minutes": 120, "min_advance_hours": 2, "max_advance_days": 60 },
"fields": [
{ "field_key": "party_size", "name": "Party Size", "field_type": "number",
"is_required": true, "validation_rules": { "min": 1 }, "field_options": {} }
]
}
]
}That is all a key gets: per type its slug, which create takes as reservation_type_slug, the three limits a booking of that type is held to, and its fields in the order the form shows them. field_key is the key in custom_data, and a field with is_required left out is refused. The dashboard reads every column of a type — its status flow, approval switch and template among them — and a key sees none of that. Read it once per page load on your server; types change when the merchant edits them.
Creating a reservation
/api/reservationswrite_reservationsCreates a reservation, subject to the availability rules above.
{
"reservation_type_slug": "restaurant-table-reservation",
"guest_name": "Noor van Elderen",
"guest_email": "noor@example.com",
"guest_phone": "+31 6 12 34 56 78",
"start_datetime": "2026-02-24T18:00:00.000Z",
"party_size": 2,
"booking_source": "website",
"custom_data": { "highchair": true }
}| Field | Required | Notes |
|---|---|---|
reservation_type_slug | yes | Or reservation_type_id. The slug is resolved for you. |
guest_name | yes | Up to 200 characters. |
guest_email | no | Needed for the confirmation email to go anywhere. |
guest_phone | no | Free text. |
start_datetime | yes | ISO 8601 UTC. Seconds must be 00. |
end_datetime | no | Filled in from the type's default duration when omitted. |
party_size | no | Positive integer. Checked against max_guests, and counted towards the slot's cover limit. The one place the party size lives: a type with its own party_size field gets it copied in, and a booking that sends only custom_data.party_size has it copied here. |
booking_source | no | One of website, api, phone, walk-in, admin. No default — the API derives it from how you authenticated, and an API key never means admin. |
custom_data | no | The fields your customer added to this reservation type, by field_key from the types above. A type's own start field is filled from start_datetime, so there is no need to send it, and a value that differs is overwritten. Its party-size field is filled from party_size the same way. |
send_confirmation_email | no | Ignored from a key, and named in data.ignored_fields: whether the guest gets an email is the venue's setting. Only the dashboard books without one. |
For every source other than admin, the server holds the reservation to the rules above, and each refusal is a 400 with a code of its own beside the sentence it always had. Branch on the code; the sentence is for your logs.
| Code | When |
|---|---|
START_TIME_IN_PAST | The start is now or earlier. Asked first — the same line the availability endpoint draws, so a slot it offered is only refused if the guest waited past its start. |
START_NOT_ON_FULL_MINUTE | The start_datetime carries seconds. |
DATE_CLOSED | The day is closed for reservations. |
OUTSIDE_OPENING_HOURS | The day is open, the time is not. The message names that day's hours. |
SLOT_MISALIGNED | The time is not on the slot grid, which steps from the day's opening time. |
OPENING_HOURS_MISCONFIGURED | That day's hours, or the slot interval, cannot be booked at all. The merchant has to correct them. |
BOOKING_TOO_SOON | Closer to now than the type's min_advance_hours. |
BOOKING_TOO_FAR_AHEAD | Further ahead than the type's max_advance_days, counted in calendar days in the venue's timezone. |
PARTY_SIZE_TOO_LARGE | party_size is above max_guests. |
SLOT_FULL | The slot has no room left for this party. Fetch availability again. |
SUBMISSION_REJECTED | Spam screening is on and the address, its domain or the phone number is on the venue's block list. |
DAILY_LIMIT_REACHED | Spam screening is on and this address or number has sent the venue's daily maximum in the last 24 hours. Tell the guest to call. |
Before any of those: 404 RESERVATION_TYPE_NOT_FOUND for a slug or id that names no type of this organization, 400 CUSTOM_FIELD_INVALID for a field of the type that is missing or breaks its rule — details names each as ["custom_data", "<field_key>"] — and 400 VALIDATION_ERROR "Validation failed" for the body itself, with the fields in details. A field the venue set up in a way that cannot be checked is not yours to correct: 500 INTERNAL_ERROR "Validation errors: Failed to validate fields", and nothing is stored — on PATCH too. Staff at the dashboard are warned rather than refused for the clock, the window, the party size and the capacity; the response carries X-Capacity-Warnings instead.
A 201 answers with the reservation. Top-level keys the create does not read are listed back in data.ignored_fields, only when there are any: a remark sent as notes did not arrive, whatever the 201 suggests. While the venue screens for spam, the same email address at the same start a second time — a double click, a retry — stores nothing new: the answer is the 201 of the reservation that already exists, with an X-Duplicate-Of header naming it, and no second email goes out.
From a key, status, internal_notes, resource_ids and send_confirmation_email are not read either, and are named in data.ignored_fields when sent. Whether a new reservation is pending or confirmed is the venue's setting, below; which table it gets, the staff's notes on it and whether the guest is emailed are the venue's too. The reservation is made, and the guest emailed, as if you had not sent them.
The reservation comes back as a key reads it everywhere — from create, from PATCH, in the list and by id: every column but what the spam screening, the provenance record and the staff write. quarantined_at, spam_signals, source_channel, source_host and source_api_key_id are the dashboard's, and so are internal_notes, created_by and rejection_reason. A reservation the screening held back is answered 201 all the same, with its id, and no email goes out; to a key it then does not exist — it is not in the list, and by id it is 404 RESERVATION_NOT_FOUND.
In the list, by id and from PATCH the reservation carries its reservation_type. To a key that is the type as types gives it, without its fields: id, slug, name, description and the three settings a booking is held to. Its status flow, approval switch and template are the dashboard's.
Which resource a reservation holds is set at the dashboard. When a key moves one the staff put on a table onto a time that table is taken, of a type that does not allow overlap, nothing is changed. The answer is a 200 with an X-Has-Conflicts: true header, and data holds conflicts, warnings and message instead of a reservation. To a key each conflict is the other reservation's reservation_id, start_datetime and end_datetime, and the warnings do not name its guest; whose it is, is for the dashboard. A reservation the spam screening held back is no conflict.
PATCH with a key holds a moved start to the same rules. Moving a reservation onto a passed time is refused with START_TIME_IN_PAST, and outside the type's notice or horizon with BOOKING_TOO_SOON or BOOKING_TOO_FAR_AHEAD — only when the start actually moves, so a reservation inside a notice tightened later can still be renamed. Growing one past max_guests is refused as on create, with PARTY_SIZE_TOO_LARGE; resending or lowering its party_size is not. A booking_source sent with PATCH is ignored. From a key so are status, internal_notes, rejection_reason, resource_ids and quarantined_at: confirming, declining or cancelling a reservation, the staff's notes, its table and whether it is held back are the venue's to change, and no status email goes out. The rest of the edit goes through, and the answer carries the status as it stands. An edit that leaves the status as it is sends no status email either: renaming the guest of a reservation the venue cancelled does not send the cancellation again. An edit left with nothing to change writes nothing and sends nothing: the answer is a 200 with the reservation as it stands. A reservation the spam screening held back is not there for a key at all: PATCH answers 404 RESERVATION_NOT_FOUND, as for an id nobody has, and changes nothing.
Requests and confirmations
enable_reservation_requests decides what a new reservation is when it lands.
| Setting | New reservation | What to tell the guest |
|---|---|---|
true | pending, awaiting approval | "Your request has been received." Do not say confirmed. |
false | confirmed immediately | "Your table is booked." |
Read the flag and write your confirmation copy from it. Saying "confirmed" for something a restaurant has not yet accepted is the kind of mistake that ends with two parties at one table. A key cannot choose otherwise: a status it sends is ignored, on create and on PATCH.
Statuses beyond those two are defined per organization, along with the emails attached to them, so do not model a fixed lifecycle in your site.
Hiding taken slots
/api/client/v1/{organizationId}/reservations/open-slotsPublicWhich slots still have room on one day. Takes date, and optionally party_size and reservation_type_slug. The client names it availability.
Full slots are simply absent from the answer, along with slots in the past and slots inside the minimum notice. What comes back is what a guest may pick, so there is nothing to subtract. Send party_size whenever you know it: without it the slots are counted for one guest, and a slot at 58 of 60 covers is free for a couple and full for a table of four.
date is required and has to be a day in the calendar: a missing, malformed or impossible one — 2026-02-30 — is 400 INVALID_DATE. A party_size that is sent and is not a whole number above zero is 400 INVALID_PARTY_SIZE, and one above max_guests is 400 PARTY_SIZE_TOO_LARGE, as the create answers it. An unknown reservation_type_slug is 404 RESERVATION_TYPE_NOT_FOUND. The older keyless address, /api/public/{organizationId}/reservations/availability, answers the same without the contract envelope and keeps answering.
An empty list means two different things depending on closed. With closed: true the venue is shut that day; with closed: false it is open and fully booked. Those are different sentences to a guest.
/api/reservationsread_reservationsExisting reservations, filterable by start_date, end_date and reservation_type_slug.
Still there for staff tooling and exports — the reservations themselves, not availability. Call it from your own server: the list contains guest names, emails and phone numbers, which is not something to hand a browser. limit is 1 to 100 and defaults to 10; follow pagination.hasMore. A start_date or end_date that cannot be read as a moment is 400 INVALID_DATE, and a reservation_type_slug that names no type is 404 RESERVATION_TYPE_NOT_FOUND.
A key does not see reservations the spam screening held back. They are left out of the list, and pagination.total, totalPages and hasMore are counted without them. quarantined is the dashboard's Spam tab and is ignored for a key, whatever it says — not refused. The dashboard sees them.
/api/reservations/{id}read_reservationsOne reservation, for a "manage my booking" page behind your own link. The client names it reservation. An id that names no reservation of this organization — or is not a uuid — is 404 RESERVATION_NOT_FOUND, and so, for a key, is a reservation the spam screening held back: word for word the answer to an id nobody has.
Live in the dashboard
Inside the dashboard, reservations, comments, statuses and settings update live across every open device, and staff can see who else is looking at the same reservation. Two people cannot unknowingly answer the same request.
None of that is something your site subscribes to. It matters to you only as a reason not to build a polling loop against the API to keep a staff-facing screen fresh: that screen already exists.
Widget checklist
- Read the settings once per page load, on your server.
- Resolve every date against
calendar_timezone, never the browser zone. - Apply overrides before weekday hours.
- Ask the availability endpoint which slots are open, and send
party_sizeso cover limits apply. - Send
booking_source: "website". - Send
start_datetimewith00seconds. - Write the confirmation copy from
enable_reservation_requests. - Keep
write_reservation_settingsoff the key. A widget reads the rules, it does not change them.