CnterDocumentationSign in

Start here

  • What Cnter is
  • Core concepts
  • Quickstart

Platform

  • Authentication
  • Conventions
  • Errors
  • Organization
  • The dashboard

Modules

  • Content Management
  • Online Ordering
  • Restaurant Ordering
  • Promotions
  • Reservations
  • Bookings
  • Contact Form

Payments

  • AgnusPAY
  • Tax

Services

  • Address autocomplete
  • Video
  • Email

Reference

  • API reference

Modules

Reservations

Opening hours, slots, date overrides and approval. Everything a booking widget needs.

A full brief for whoever builds against this.

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#

GET/api/reservation-settingsread_reservation_settings

The resolved opening hours, slot configuration, party size cap and the venue timezone.

FieldUse
opening_hoursThe resolved schedule that applies to reservations — weekly pattern, exception dates, timezone, and which source it came from.
time_slot_configThe slot interval, and the first and last slot of the week.
max_guestsA 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_configHow many reservations and how many covers fit in one slot. Absent or enabled: false means no limit anywhere.
enable_reservation_requestsWhether new reservations arrive as a request awaiting approval.
calendar_timezoneThe 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.

Use calendar_timezone, not the visitor's

It comes from the organization, set under Settings then Organization then General, and defaults to Europe/Amsterdam. Generate slot labels and resolve dates against it. A widget that uses the browser's zone puts an Amsterdam venue's 18:00 sitting at 12:00 for a visitor in New York.

Which schedule you get#

business_hours is gone

Opening hours belong to the business, not to this module, so they moved to the organization in August 2026. business_hours and time_slot_config.availability_date_overrides no longer exist here — a request still sending either is rejected with a message naming the new home, rather than being accepted and ignored. Read opening_hours below instead.

json
{
  "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.

Opening hoursThe shape, how a date resolves, and what a module may do differently.

Slot configuration#

json
{
  "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

  1. 01

    A closed exception wins

    An entry in exceptions for that date with mode: "closed" means no slots. Stop here.

  2. 02

    An open exception replaces the weekday

    An entry with mode: "open" uses its own open and close, and ignores the weekday's enabled flag entirely. That is what makes "normally closed on Sunday, but not this Sunday" expressible.

  3. 03

    Otherwise the weekday applies

    Fall back to opening_hours.weekly for 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.

Slots align to the day's opening time

Not to the hour. A day opening at 09:15 on a 30 minute interval offers 09:15, 09:45, 10:15 — a "round" 09:30 is rejected. Step from the opening time rather than rounding, or the server will refuse times your own picker offered.

Slot generation, venue timezone
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.

OrganizationWhere opening hours live, and how to read them without a key.

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.

json
{
  "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 }] }
  ]
}
  1. 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.

  2. 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.

  3. 03

    The default

    Applies to every slot no rule above reached.

  4. 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.

max_covers counts arrivals, not the room

It limits the guests starting at that time, not the guests sitting in the building. On 30-minute slots with 60 covers each, 120 people can be seated between 19:00 and 19:30. Limiting how many are in the room at once needs a rule that spans slots, which does not exist yet.

Pending requests occupy a slot

A request awaiting approval holds its place, otherwise a limit would enforce nothing at all for the many venues that leave enable_reservation_requests on. Declining one frees the slot immediately. Anything the spam screening holds back does not count towards a limit at all, so junk cannot fill a Saturday.

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#

GET/api/reservation-typesread_reservation_settings

Which 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.

json
{
  "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#

POST/api/reservationswrite_reservations

Creates a reservation, subject to the availability rules above.

json
{
  "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 }
}
FieldRequiredNotes
reservation_type_slugyesOr reservation_type_id. The slug is resolved for you.
guest_nameyesUp to 200 characters.
guest_emailnoNeeded for the confirmation email to go anywhere.
guest_phonenoFree text.
start_datetimeyesISO 8601 UTC. Seconds must be 00.
end_datetimenoFilled in from the type's default duration when omitted.
party_sizenoPositive 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_sourcenoOne 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_datanoThe 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_emailnoIgnored 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.

booking_source is decided by your key

It is not a label. admin skips the availability check, because someone standing at the pass taking a phone call needs to be able to squeeze a table in — so a key can never be admin. Sent from a key, admin or nothing at all is stored as website. phone and walk-in are yours to send, and both are held to every rule below.

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.

CodeWhen
START_TIME_IN_PASTThe 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_MINUTEThe start_datetime carries seconds.
DATE_CLOSEDThe day is closed for reservations.
OUTSIDE_OPENING_HOURSThe day is open, the time is not. The message names that day's hours.
SLOT_MISALIGNEDThe time is not on the slot grid, which steps from the day's opening time.
OPENING_HOURS_MISCONFIGUREDThat day's hours, or the slot interval, cannot be booked at all. The merchant has to correct them.
BOOKING_TOO_SOONCloser to now than the type's min_advance_hours.
BOOKING_TOO_FAR_AHEADFurther ahead than the type's max_advance_days, counted in calendar days in the venue's timezone.
PARTY_SIZE_TOO_LARGEparty_size is above max_guests.
SLOT_FULLThe slot has no room left for this party. Fetch availability again.
SUBMISSION_REJECTEDSpam screening is on and the address, its domain or the phone number is on the venue's block list.
DAILY_LIMIT_REACHEDSpam 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.

SettingNew reservationWhat to tell the guest
truepending, awaiting approval"Your request has been received." Do not say confirmed.
falseconfirmed 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#

GET/api/client/v1/{organizationId}/reservations/open-slotsPublic

Which 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.

This replaces reading the reservation list

Until now the advice here was to fetch every reservation for the day and subtract the overlapping times. That means pulling guest names, email addresses and phone numbers into a booking site in order to decide whether to grey out 19:00. The availability endpoint answers the same question with counts and nothing else, and needs no API key.

GET/api/reservationsread_reservations

Existing 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.

GET/api/reservations/{id}read_reservations

One 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#

  1. 01Read the settings once per page load, on your server.
  2. 02Resolve every date against calendar_timezone, never the browser zone.
  3. 03Apply overrides before weekday hours.
  4. 04Ask the availability endpoint which slots are open, and send party_size so cover limits apply.
  5. 05Send booking_source: "website".
  6. 06Send start_datetime with 00 seconds.
  7. 07Write the confirmation copy from enable_reservation_requests.
  8. 08Keep write_reservation_settings off the key. A widget reads the rules, it does not change them.
PreviousPromotionsNextBookings

On this page

  • Reading the rules
  • Which schedule you get
  • Slot configuration
  • Capacity per slot
  • Reservation types and their fields
  • Creating a reservation
  • Requests and confirmations
  • Hiding taken slots
  • Live in the dashboard
  • Widget checklist