Modules
Bookings
Appointments with a date, a time and a meeting type. The simplest module in the platform.
Bookings covers the appointment: someone picks a moment, leaves their details, and both sides get an email. It is deliberately small. It knows when the venue takes bookings and which start times are still free for an appointment of a given length — and it tells a website only the times, never who booked the others. If you need tables, party sizes and capacity, that is Reservations.
When bookings are taken
/api/client/v1/{organizationId}/bookings/hoursPublicPer day, the hours the venue takes a booking in, or that it is closed, and whether it takes requests at all right now. No key: a page can call it. The client names it hours.
{
"ok": true,
"contract": 1,
"data": {
"timezone": "Europe/Amsterdam",
"accepting": true,
"days": [
{ "date": "2026-10-09", "open": "09:00", "close": "17:00" },
{ "date": "2026-10-10", "closed": true }
]
}
}| Parameter | Default | Notes |
|---|---|---|
from | today | The first day, YYYY-MM-DD. Today means today in the venue's timezone, not the visitor's. A date that is not a day is 400 INVALID_DATE. |
days | 7 | How many days from from. At most 31: more is not refused, it is capped, and the answer has 31 days. Anything that is not a whole number of at least one is 400 INVALID_DAYS. |
These are the same hours a booking from a key is held to, read the same way, so a day open here is a day requestBooking takes and a closed one is a day it refuses. An open day carries open and close as HH:mm; a booking may start at open or any minute after it, up to but not at close. A day whose hours close at or before they open is closed: true, because no minute of it can be booked. accepting: false means the venue has switched booking requests off: every booking from a key is refused until it switches them back on, whatever the hours say.
Without from the answer is never cached, because "today" moves at midnight. With from it may be kept for a minute, which is how long a merchant's change to their hours can take to show. If the hours cannot be read the answer is 503 HOURS_UNAVAILABLE, never an empty week that would read as closed every day.
Free start times
/api/client/v1/{organizationId}/bookings/open-slotsPublicPer day, the start times still free for an appointment of duration minutes. Times only — nothing about who booked the others. No key: a page can call it. The client names it availability.
{
"ok": true,
"contract": 1,
"data": {
"timezone": "Europe/Amsterdam",
"accepting": true,
"duration_minutes": 45,
"step_minutes": 15,
"buffer_minutes": 0,
"lead_minutes": 120,
"days": [
{ "date": "2026-10-13", "closed": false, "slots": ["16:00", "16:15", "17:30", "19:45", "20:00", "20:15"] },
{ "date": "2026-10-14", "closed": true, "slots": [] }
]
}
}| Parameter | Default | Notes |
|---|---|---|
duration | required | How long the appointment takes, in whole minutes, 1 to 720. Work it out on your server from what the visitor chose; a number from the browser is a number a visitor can change. Missing or out of bounds is 400 INVALID_DURATION. |
from | today | The first day, YYYY-MM-DD, in the venue's timezone. A date that is not a day is 400 INVALID_DATE. |
days | 7 | How many days from from. At most 62: more is capped, not refused. Anything that is not a whole number of at least one is 400 INVALID_DAYS. |
step | 15 | The grid start times fall on, in minutes, 5 to 240: on the clock's multiples of it, so with 15 at :00, :15, :30 and :45, from the first one at or after opening. 400 INVALID_STEP outside that. |
buffer | 0 | Minutes kept free after every appointment — the ones already booked and the one being asked about alike — 0 to 240. 400 INVALID_BUFFER outside that; never capped, because a shorter buffer than asked for is two appointments too close together. |
lead | 0 | How far from now the first start may be, in minutes, 0 to 10080 (a week): 120 offers nothing in the next two hours. Counted in real time, so the night the clocks change does not move it. 400 INVALID_LEAD outside that. |
A start is offered when the whole appointment fits inside that day's hours — it may end exactly at closing time — when it is later than now and at least lead minutes away, and when the appointment plus buffer overlaps no booking plus buffer. The hours are the ones above, exceptions included, so a day hours calls closed is closed: true here. An open day with nothing left is closed: false with an empty slots: that is fully booked, not closed. While accepting is false every day has an empty slots, because every booking from a key would be refused.
What holds time is a booking that is pending or accepted. A request from a website holds its time from the moment it arrives, before the venue has looked at it; a declined, cancelled or completed one does not, and neither does one the spam screening held back. Each holds as long as its duration_minutes, and an hour when it has none — which is every booking made without one, by the dashboard or by a site that does not send it.
Never cached: this is occupancy, and a time that was taken a minute ago must not be on offer. If the bookings or the hours cannot be read the answer is 503 AVAILABILITY_UNAVAILABLE, never an empty calendar. It narrows double-booking and does not lock anything: two visitors who pick the same time at the same moment can both send it. Ask again for that one day just before you post the booking, and tell the visitor the time was just taken when it is gone.
Creating a booking
/api/bookingswrite_bookingsCreates a booking and sends the confirmation email. Returns 201 with the stored record. The client names it requestBooking.
{
"name": "Noor van Elderen",
"email": "noor@example.com",
"phone": "+31 6 12 34 56 78",
"date": "2026-09-14",
"time": "14:30",
"meeting_type": "video-call",
"message": "Would prefer the afternoon if possible."
}| Field | Required | Notes |
|---|---|---|
name | yes | Non-empty once spaces around it are trimmed, and at most 255 characters after trimming. |
email | yes | A valid address, at most 255 characters. It is where the confirmation goes. |
phone | yes | 3 to 50 characters, free text. Required by the record, so an absent phone is a 400 rather than a 500. |
date | yes | Calendar date, YYYY-MM-DD, in the venue's timezone. It has to be a day that exists: 2026-02-30 is 400 INVALID_DATE. |
time | yes | Wall clock, HH:mm, 24 hour, in the venue's timezone. |
meeting_type | yes | Exactly in-person or video-call. |
message | no | Free text from the person booking. |
duration_minutes | no | How long the appointment takes, a whole number of minutes from 1 to 720. Send the same number you asked the free times about: it is how long this booking holds its time there. Without it the booking holds an hour. Not a detail of the person, so it is in every answer a key gets. |
honeypot | no | A hidden field your form includes and a person never fills in; up to 200 characters. Only while the venue has spam screening on: a filled one is stored but held back — no confirmation email, still a 201. With screening off it is ignored and the booking goes through as any other. |
A field that breaks one of these rules is 400 VALIDATION_ERROR with the message Validation failed, whatever the rule; which field it was, and why, is in details — "Name must be at most 255 characters", "Phone number must be at most 50 characters". Keys the booking does not know are not refused and not stored: the 201 names them in data.ignored_fields, in the order you sent them, and leaves it out when there are none. A remark sent as notes rather than message is in that list, and did not arrive.
The organization comes from the API key. Do not send one in the body: it is taken out of band, and a body field would only be a second source of truth. An organization_id sent anyway is not read, and is in ignored_fields.
What a website is held to
A booking made with a key is a website asking, and it is held to the venue's calendar. Each refusal has a code of its own beside a sentence written for your logs; branch on the code and write the visitor's words yourself.
| Code | When |
|---|---|
403 BOOKINGS_DISABLED | The venue has switched booking requests off. accepting on the hours says so in advance. |
400 INVALID_DATE | The date is well formed and does not exist. |
400 DATE_CLOSED | The venue takes no bookings that day: 2026-10-10 is not available for booking. |
400 OUTSIDE_OPENING_HOURS | The day is open, the time is not: Start time must be between 09:00 and 17:00 (2026-10-09). |
400 OPENING_HOURS_MISCONFIGURED | That day's hours close at or before they open. Nothing in the request; the merchant has to correct them. |
400 START_TIME_IN_PAST | The date and time, in the venue's timezone, are now or earlier. Asked after the hours, so a past date on a closed day still answers DATE_CLOSED. |
400 SUBMISSION_REJECTED | Spam screening is on and the address, its domain or the phone number is on the venue's block list. |
400 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 visitor to call. |
Staff booking from the dashboard are held to none of these but the date: a phone call for a time the venue is technically shut is still a real appointment, and a booking can be written down after the fact.
What comes back
The stored booking, as a key reads it everywhere: id, organization_id, the fields above, status, created_at and updated_at. duration_minutes is always there, null for a booking made without one. time is HH:mm — the dashboard reads the same column with seconds, 14:30:00, and a key does not. A key does not get what the spam screening and the provenance record write: quarantined_at, spam_signals, source_channel, source_host and source_api_key_id are the dashboard's.
status is where the booking stands: pending for one a website made, until the venue accepts it, then accepted, declined, completed or cancelled. Staff move it from the dashboard; a booking made there starts as accepted. Nothing a key can call changes it.
Reading bookings
/api/bookingsread_bookingsLists bookings for the organization, newest first, paginated. startDate alone narrows it to that day; with endDate to that range. The client names it bookings.
Useful for a back-office sync or a customer-facing "your appointments" page behind your own login. It is not something a public storefront needs — not even for a calendar, which is what availability is for — and a storefront key should not carry the scope. A startDate or endDate that is not a day is 400 INVALID_DATE.
A key does not see bookings the spam screening held back. They are left out of the list, and pagination.total, totalPages and hasMore are counted without them. The dashboard sees them.
/api/bookings/{id}read_bookingsOne booking, by the id requestBooking answered with. The client names it booking.
An id that names no booking of this organization is 404 BOOKING_NOT_FOUND, and so is an id that is not a uuid, and — for a key — a booking the spam screening held back: word for word the answer to an id nobody has. Changing or removing a booking is dashboard work, and a key is refused there.
Reminders
/api/bookings/remindersadmin, ownerSends reminders for this organization's upcoming bookings. Dashboard only.
There is nothing to call from a website. This is the dashboard's button, for the signed-in organization and no other; a key is answered as a call without a session. And it sends nothing at all unless the server runs with REMINDERS_ENABLED=true. The automated run exists and is not scheduled, so today no reminder goes out on its own — do not promise one on your confirmation page.
/api/bookings/reminders takes POST only: a GET is not an endpoint there. The automated run is a different route, the booking-reminders cron, and it is internal: it answers GET because that is the verb Vercel Cron sends (and POST, which does the same), only with Authorization: Bearer <CRON_SECRET>, and it is no API for a site or a key. It is off by default too (REMINDERS_ENABLED), and nothing schedules it today.
Building a booking form
- 01
Ask for the free times
Read
availabilitywith the length of what the visitor chose, worked out on your server, for the weeks you show. Grey out the closed days, offer only the times inslots, and show the phone number instead of the calendar whileacceptingis false. Without a calendar,hoursis enough for a request form. - 02
Collect the six required fields
Name, email, phone, date, time and meeting type. Label above input, error below, no placeholder standing in for a label. Add the hidden
honeypotfield and post whatever lands in it. - 03
Post from your server
The key must not be in the browser. Your form posts to your own route, which asks
availabilityfor that one day again and then posts to Cnter — withduration_minutes, so the booking holds as long as the appointment takes. - 04
Confirm without promising
Say the request is in and that a confirmation is on its way. It arrives as
pending, and your customer decides what happens next. - 05
Handle the 400 inline
Branch on the code. For
VALIDATION_ERROR,details[].pathnames the field: put your own sentence next to it rather than in a toast. ForDATE_CLOSED,OUTSIDE_OPENING_HOURSandSTART_TIME_IN_PAST, ask for another moment.