Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Gaplessly booking Guest self-service API
version: 1.0.0
summary: Two booking engines sharing plumbing, never booking logic.
description: 'Gaplessly runs **two booking engines**: appointments (a client books one service with one staff member for a set duration) and table reservations (a guest books a table for a party inside a service period). They are two products, not one product with a flag. Each organization runs exactly one, chosen when it signs up, and `businessType` on `GET /api/v1/organization` says which.
This document covers the surfaces built for other software:
- **`/api/v1/*`**: an organization''s own data with an API key, sent as `Authorization: Bearer sk_live_…`: read it, find free times, and book, change or cancel on a guest''s behalf. Scoped and rate limited.
- **`/api/book/{slug}/*`**: the public booking endpoints a business''s booking page calls. No authentication.
- **`/api/book/{slug}/manage/{token}`**: guest self-service. The token in the guest''s booking link is the whole credential.
- **`/api/mcp`**: a Model Context Protocol endpoint for AI assistants helping a guest find a time. No authentication.
Guides, a quickstart and the versioning and deprecation policy: /docs.'
contact:
name: Gaplessly
url: mailto:support@gaplessly.com
servers:
- url: https://gaplessly.com
description: 'Production. There is deliberately no CORS policy: an API key is a secret, so call `/api/v1` from a server, never from browser JavaScript.'
tags:
- name: Guest self-service
description: 'The token in a guest''s booking link is the whole credential: move or cancel the booking, answer the intake form, or message the venue. One set of endpoints serves both engines, because a link points at a booking, not at a product.'
paths:
/api/book/{slug}/walkin/{token}:
get:
summary: Place in the walk-in line
description: One guest's status, by the token from joining. The token is the whole credential; an unknown one is a 404. `state` is `waiting` (with `position`, 1 is next, and `quotedMinutes` when the host quoted a wait), `ready` (come to the host stand), `seated`, `left` or `closed`. The status page polls this about every 20 seconds while visible.
tags:
- Guest self-service
operationId: getWalkinStatus
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The status token returned when joining the walk-in line.
responses:
'200':
description: The status.
content:
application/json:
schema:
type: object
required:
- companyName
- state
- position
- quotedMinutes
- waitedMinutes
- partySize
- name
- canLeave
properties:
companyName:
type: string
state:
type: string
enum:
- waiting
- ready
- seated
- left
- closed
position:
type:
- integer
- 'null'
quotedMinutes:
type:
- integer
- 'null'
waitedMinutes:
type: integer
partySize:
type: integer
name:
type: string
description: The first name given when joining.
canLeave:
type: boolean
'404':
description: No walk-in with that token at this venue.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
summary: Leave the walk-in line
description: The guest's own cancel, by the token from joining. Allowed while waiting or after the table was called; everyone behind them moves up one.
tags:
- Guest self-service
operationId: leaveWalkinLine
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The status token returned when joining the walk-in line.
responses:
'200':
description: Left the line.
content:
application/json:
schema:
type: object
required:
- ok
properties:
ok:
const: true
'404':
description: No walk-in with that token at this venue.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Already seated, gone or closed.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: The change could not be stored.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/book/{slug}/manage/{token}:
patch:
summary: Change a booking, or its dietary preferences and occasion
description: 'A guest moves their own booking to another time. One endpoint for both engines: a manage link points at a booking, not at a product, so the engine is worked out from the booking and the caller does not choose.
Only the time changes: same service, same staff member, same party size. That is what lets a guest reschedule go through without staff review.
The new time must respect the venue''s notice period too, so a guest cannot sidestep the cutoff by moving to a slot an hour from now. The slot is re-derived server-side, and a booking the venue cancelled mid-flow is not brought back.'
tags:
- Guest self-service
operationId: guestRescheduleBooking
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The booking's `manage_token` (a uuid) from its confirmation email. This IS the credential; there is no session. A non-uuid string is rejected before it reaches Postgres and reads as "link not valid".
requestBody:
required: true
content:
application/json:
schema:
type: object
minProperties: 1
properties:
startsAt:
type: string
format: date-time
description: Must be at least 60 seconds away, at least `guest_manage_cutoff_hours` away, and match a computed slot exactly.
partySize:
type: integer
minimum: 1
maximum: 30
description: Table bookings only.
dietaryTags:
type: array
items:
type: string
description: Table bookings only. Each one of the widget's fixed dietary tags; replaces the list.
occasion:
type:
- string
- 'null'
maxLength: 80
description: Table bookings only. Null or blank clears it.
reconfirm:
const: true
description: 'The guest''s "Yes, we''re coming" (F11 step 4). Both engines; never with any other field. Stamps `reconfirmed_at` once: a second press answers with the time already stored. Only a confirmed booking that has not ended can reconfirm (403 otherwise). Not gated on the self-service policy.'
responses:
'200':
description: 'Changed. A change notification is queued after the response is sent. A dietary/occasion save answers `{ details: { dietaryTags, occasion } }` instead, and sends nothing. A reconfirm answers `{ reconfirmedAt }`.'
content:
application/json:
schema:
type: object
properties:
reconfirmedAt:
type: string
format: date-time
booking:
type: object
properties:
id:
type: string
format: uuid
starts_at:
type: string
format: date-time
ends_at:
type: string
format: date-time
party_size:
type:
- integer
- 'null'
details:
type: object
properties:
dietaryTags:
type: array
items:
type: string
occasion:
type:
- string
- 'null'
'400':
description: Nothing to change, an unparseable time or party size, a time already past or inside the org's notice period, a party size on an appointment, an unknown dietary tag, an occasion with a web address, dietary tags where the venue does not ask for them, or a change and a preferences edit in one request.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: 'The venue''s policy refuses: guest self-service is switched off, the booking is in a status a guest may no longer act on (cancelled, seated, completed, no_show), it has already started, or it is now inside the notice period. The body carries a guest-readable sentence naming the venue.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: The slug, the token, or the pairing of the two does not resolve. Deliberately indistinguishable; "That link is no longer valid".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: The requested time (or party size) is no longer free, or was taken between the availability read and the update; the new party size is above the venue's online limit; or the change would owe a larger deposit, or a card, than the booking holds. Carries a guest-readable sentence.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: The update errored for a reason other than an overlap.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
summary: Answer the venue's intake form
description: 'The guest answers whatever questions the venue set up for this booking. Serves both engines: the token points at a booking, and the engine is worked out from it.
PUT rather than POST because there is exactly one response per booking: a guest reopening their link edits their answers rather than stacking a second submission.
The form is looked up server-side, not taken from the body, since the venue may have switched or retired forms since the page was drawn. A service-specific form wins over the venue-wide one, and only active forms are served. Answers are validated against it: required questions present, dropdown answers within their `options`, lengths capped.
Deliberately NOT gated on the venue''s guest self-service policy, unlike PATCH and DELETE: a venue that switched off guest rescheduling still asked these questions, and the hour before a visit is exactly when someone remembers their allergy.'
tags:
- Guest self-service
operationId: guestSubmitIntake
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The booking's manage token.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- answers
properties:
answers:
type: object
description: Keyed by each question's stable `key`. Values are strings for `text`, `textarea` and `select`, booleans for `checkbox`. A key the form does not define is a 400 rather than being dropped; silently discarding something a guest typed is the worst available outcome for a form about allergies. Blank optional answers are omitted from storage rather than stored as `""`, so staff read "not answered" instead of a deliberate blank.
additionalProperties:
type:
- string
- boolean
responses:
'200':
description: Saved. Staff see it on the booking.
content:
application/json:
schema:
type: object
required:
- ok
properties:
ok:
const: true
'400':
description: 'An answer failed validation. The message names the question: a missing required answer, an unticked required checkbox, a dropdown answer outside its choices, a value of the wrong type, an over-length answer (500 characters for a short answer, 2000 for a long one), or a key this form does not define.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The booking is cancelled, or it has already finished; there is nothing left to prepare for. Carries a guest-readable sentence.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 'The link does not resolve, **or** this org has no active intake form for this booking. The two are the same status on purpose: from outside, "no such link" and "nothing to fill in" are equally not-a-resource.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: A second submit raced the first; two tabs, or a double tap, and the one-response-per-booking index caught the loser.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: The insert or update errored.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
summary: Message the venue
description: 'Allowed regardless of the change cutoff and on any status, unlike PATCH and DELETE. "I am running late" and "why was this cancelled" are exactly the moments someone needs to reach a venue; refusing them because the booking is two hours away would be backwards.
A manage link is public to anyone holding it, so there is a flood cap: 20 guest messages per booking, after which the guest is told to contact the venue directly. That is a cheap guard, not real rate limiting; there is none anywhere in this API yet.'
tags:
- Guest self-service
operationId: guestMessageVenue
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The booking's manage token.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string
minLength: 1
maxLength: 2000
responses:
'200':
description: Sent. It appears in the venue's Inbox.
content:
application/json:
schema:
type: object
required:
- ok
properties:
ok:
const: true
'400':
description: An empty message, or one over 2000 characters.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: The link does not resolve.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: This booking already has 20 guest messages against it. The only 429 in the API, and it is a per-booking cap rather than a rate limit.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: The insert failed.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
summary: Cancel a booking
description: Sets status to `cancelled` rather than deleting, so the venue keeps the history and the slot or table is released by exactly the rules a staff cancellation uses. Compare-and-set on the current status.
tags:
- Guest self-service
operationId: guestCancelBooking
security: []
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: The organization's public booking slug, i.e. the `{slug}` in /{slug}. Only orgs with status `active` resolve; anything else is a 404.
- name: token
in: path
required: true
schema:
type: string
description: The booking's manage token.
responses:
'200':
description: Cancelled. A cancellation notification is queued after the response is sent.
content:
application/json:
schema:
type: object
required:
- ok
properties:
ok:
const: true
'403':
description: The policy refuses; same set of reasons as PATCH, with a guest-readable sentence. Staff can still cancel from the dashboard, and any deposit still refunds or forfeits by the usual rule.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: The link does not resolve.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Its status changed underneath the request; in practice, it was already cancelled.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: The update errored.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
required:
- error
properties:
error:
type: string
description: A human-readable sentence. This app never returns machine-readable error codes; the HTTP status is the code.
description: 'The body of every non-2xx response: a human-readable sentence in `error`. `/api/v1` responses add a machine-readable `code`; match on that, never on the sentence.'
securitySchemes:
bearerApiKey:
type: http
scheme: bearer
bearerFormat: sk_live_<16 hex key id>_<43 char base64url secret> (sk_test_ for a test key)
description: 'An organization admin creates keys in the dashboard under **Developer**; API access is included on Pro (Venue Pro for venues). The raw key is shown once, when it is created. Send it as `Authorization: Bearer sk_live_…` and nowhere else: no query parameter, custom header or cookie, because a credential in a URL ends up in logs and browser history.
- **Scopes are the whole permission model.** A key carries `resource:action` scopes, never a role, and `write` does not imply `read`.
- **Engine endpoints check the engine.** A key from a table-reservations organization gets `403 wrong_vertical` from an appointments endpoint, and the other way round, even when it holds the scope.
- **Keys cannot manage keys.** Keys are created and revoked only in the dashboard, so a leaked key cannot mint another.
- **Errors carry a stable `code`** beside the `error` sentence: `missing_authorization`, `invalid_key`, `key_revoked`, `key_expired`, `organization_inactive` (401); `insufficient_scope`, `wrong_vertical`, `plan_required` (403); `rate_limited` (429). Match on `code`, never on the sentence.
- **Rate limits are per key**: a fixed 60-second window, 120 requests a minute by default. Every response states the policy in `RateLimit-Policy`, a response to a known key adds what is left in `RateLimit`, and a 429 adds `Retry-After`.
- **Every list is `{ data, nextCursor }`**, keyset paged and camelCase. `nextCursor` is null exactly when there are no more rows.
- **`/api/v1/` is a real version.** A breaking change ships as `/api/v2/`, and `v1` keeps answering unchanged for at least 6 months with `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers on every response. Only a security fix can shorten that window, and the notice says why.'
externalDocs:
description: Versioning and deprecation policy
url: /docs/conventions#versioning