API reference
Programmatic access to event types, bookings, availability, contacts, one-off scheduling links and webhook subscriptions. The machine-readable document behind this page is openapi.json — an OpenAPI 3.1 spec you can load into Postman, Insomnia or your own code generator.
The events Calemander sends to you are listed under Webhook events at the foot of this page. Registering an OAuth client — redirect URI rules, discovery, dynamic client registration and the scope list — is covered separately, in the developer documentation.
The /api/v1 surface of a Calemander workspace.
What a credential opens
Every endpoint is user-scoped. A credential resolves to exactly one user in one workspace, and every read is filtered to that user's own rows (WHERE user_id = ?). There is no parameter anywhere in this API for asking about a different user. The one deliberate widening is webhook subscriptions: a workspace admin also sees and manages organization-scoped subscriptions created by other members.
Which host to call
A workspace lives on its own hostname. Send requests to the origin that GET /api/v1/me reports as workspace.origin (OAuth clients are told the same thing as site_url in the token response). For the deployment's default workspace that origin is the apex domain; for every other workspace it is https://{workspace}.calemander.com.
Authentication
Two credential types, told apart by token prefix, both presented as Authorization: Bearer <token>:
- API key —
cm_live_followed by 40 hex characters. Created in the dashboard under API keys with the scopes you tick and an optional expiry; only its hash is stored, so the plaintext is shown exactly once. A key opens the workspace it was created in and no other, acts as the member who created it, and stops working when that person leaves the workspace or when it expires. Keys were reset in September 2026 when they moved to resource scopes: any key created before then no longer works and must be re-created. - OAuth 2.0 access token —
cmo_at_prefix, issued by the authorization-code flow with PKCE. Access tokens live one hour; refresh tokens ninety days.
Both carry the same scopes and both resolve to the same {userId, scopes}, so no endpoint behaves differently depending on which one you used.
Scopes
Scopes are resource:action strings and a credential holds exactly the ones it was granted — there is no implication ladder (bookings:write does not include bookings:read; tick both). Every operation below names the scope it requires. A request whose credential lacks it is refused with 403 and a message naming the scope (Insufficient scope. Required: bookings:write).
| Scope | Opens | |---|---| | bookings:read | GET /bookings — your bookings, with each attendee's name, email and notes | | bookings:write | POST /bookings, PATCH /bookings/{id}, POST/DELETE /bookings/{id}/no-show | | event_types:read | GET /event-types | | event_types:write | POST /event-types, PATCH /event-types/{id} | | availability:read | GET /availability/{slug} | | contacts:read | GET /contacts — names, emails, phone numbers, companies | | scheduling_links:read | GET /one-off-links | | scheduling_links:write | POST /one-off-links | | webhooks:read | GET /webhooks, GET /webhooks/{id} | | webhooks:manage | POST /webhooks, DELETE /webhooks/{id} — your own subscriptions; an organization-scoped subscription additionally needs the credential's owner to be a workspace admin | | check_in:read | GET /api/check-in/sessions, GET /api/check-in/sessions/{id} (the check-in API, outside /api/v1) | | check_in:write | check-in, walk-in and undo on a session | | check_in:manage | no-show, close and reopen a session |
GET /api/v1/me needs no particular scope: any valid credential may ask what it is.
A credential never exceeds its owner's role. Scopes are checked against the member's live workspace role on every request, so a change of role applies to the next call. Nothing in this API reaches workspace settings, members or billing, whatever the credential holds. Older OAuth grants that were consented to with the legacy words read / write keep exactly the access those words opened at the time (read = every *:read above except check-in; write adds bookings:write, scheduling_links:write, webhooks:manage); the consent screen shows new grants as the resource scopes they resolve to.
Rate limiting
60 requests per minute per credential, counted in a fixed 60-second window. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp in seconds). Over the limit the API answers 429 with a Retry-After header.
Error bodies
Almost every error is {"message": "..."}. The single exception is the 429, which is {"error": "Rate limit exceeded", "retryAfter": <unix seconds>} — it is produced before routing, by the rate limiter, rather than by a handler.
Times
All timestamps are UTC. Booking times are returned as they are stored; POST /api/v1/bookings normalises the times you send to UTC before persisting them.
Endpoints
Identity
Who the calling credential belongs to.
GET /api/v1/me
Identify the calling credential
Returns the user the credential opens, the workspace it belongs to, and how it authenticated. Everything in the response is derived from the credential itself; there is no way to ask about another user.
This is the connection-test endpoint: it is what an integration calls once at connection time to check a credential is live and to build a human-readable label for the connection.
Responses
| Status | Body | Description |
|---|---|---|
| 200 | Identity | The credential's identity. |
| 401 | Error | The credential is missing, invalid, or no longer resolves to a user. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"user": {
"id": "usr_9f21",
"email": "ada@example.com",
"name": "Ada Lovelace",
"slug": "ada",
"timezone": "Europe/London",
"role": "admin",
"scheduling_url": "https://acme.calemander.com/ada"
},
"workspace": {
"slug": "acme",
"origin": "https://acme.calemander.com"
},
"auth": {
"method": "api_key",
"scopes": [
"bookings:read",
"bookings:write",
"event_types:read"
]
},
"api_key": {
"id": "key_1",
"name": "Zapier",
"prefix": "cm_live_a1b2",
"scopes": [
"bookings:read",
"bookings:write",
"event_types:read"
],
"expires_at": "2027-08-01T00:00:00.000Z",
"created_at": "2026-08-01T00:00:00.000Z"
}
}Event Types
The bookable meeting templates a user offers.
GET /api/v1/event-types
List event types
Requires the event_types:read scope.
The authenticated user's own event types, newest first. Soft-deleted event types are never returned. Inactive ones are excluded unless include_inactive=true.
hosts is populated only for round_robin and collective event types; a one-on-one event type reports an empty list.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| include_inactive | query | "true" | "false" | Include event types with isActive: false. Only the exact string true enables this; any other value is treated as false. |
| page | query | integer | 1-based page number. Values below 1 and unparseable values fall back to 1. |
| limit | query | integer | Rows per page. Clamped to 1–100; unparseable values fall back to 50. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | A page of event types. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/event-types
Create an event type
Requires the event_types:write scope.
Creates an event type owned by the authenticated user, through the same validator the dashboard's form uses (slug rules, length limits, member checks, plan caps). The 201 body is the created event type in the same shape GET /api/v1/event-types returns.
On a plan whose active-event-type cap is full, the event type is created switched off rather than refused, and the body carries deactivatedByPlan: true with planMessage — surface that to the user, because an inactive event type is a booking page that simply does not exist.
A new group event type (maxSeats above 1) on a plan with check-in starts with a 10-minute auto-close and credits spent at attendance; on other plans, or for a one-on-one, it starts with no auto-close and booking-time credits. Pass autoCloseMinutes / creditTiming to set them explicitly.
Request body (required)
| Field | Type | Description |
|---|
{
"name": "Morning Flow",
"slug": "morning-flow",
"durationMinutes": 60,
"maxSeats": 12
} Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | Created. |
| 400 | Error | Body was not a JSON object, a field has the wrong type or an unknown name, a required field is missing, the slug is malformed or already taken, a member id is not in this workspace, or a check-in value is out of range. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 402 | Error |
|
| 403 | Error | The credential lacks the |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PATCH /api/v1/event-types/{id}
Update an event type
Requires the event_types:write scope.
Changes only the fields present in the body, on one of the authenticated user's own event types. An event type that exists but belongs to another member is 404, the same as one that does not exist. The 200 body is the updated event type in the GET /api/v1/event-types shape.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | The event type id. |
Request body (required)
| Field | Type | Description |
|---|
{
"name": "Evening Flow",
"isActive": true
} Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Updated. |
| 400 | Error | Body was not a JSON object or had nothing to update, a field has the wrong type or an unknown name, the slug is malformed, a member id is not in this workspace, |
| 401 | Error | No credential was presented, or it did not resolve. |
| 402 | Error | Switching the event type on would exceed the plan's active-event-type cap, or auto-close / attendance credits were requested on a plan without check-in. |
| 403 | Error | The credential lacks the |
| 404 | Error | No event type with this id is owned by the authenticated user. |
| 409 | Error | The new slug is already used by another event type in the workspace. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
Bookings
Scheduled meetings: list, create, and change status.
GET /api/v1/bookings
List bookings
Requires the bookings:read scope.
The authenticated user's own bookings, most recent start time first. All filters combine with AND.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| status | query | string | Exact match on the stored status. Not validated against a list — an unknown value simply matches nothing. Values in use: confirmed, pending, canceled, rescheduled, payment_pending, no_show. |
| event_type_id | query | string | Only bookings for this event type. |
| from | query | string | Lower bound on startTime, inclusive. Compared as a string against the stored UTC value, so pass the same ISO 8601 shape the API returns. |
| to | query | string | Upper bound on startTime, inclusive. Same string-comparison note as from. |
| payment_provider | query | string | Exact match on which rail holds this booking's money. external means your own checkout collected it — ?status=payment_pending&payment_provider=external is "list unpaid reservations". |
| attendee_email | query | string | Exact match on the attendee's email, case-insensitively. Not a substring search: a@b.com will not match aa@b.com. |
| page | query | integer | 1-based page number. Values below 1 and unparseable values fall back to 1. |
| limit | query | integer | Rows per page. Clamped to 1–100; unparseable values fall back to 50. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | A page of bookings. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings
Create a booking
Requires the bookings:write scope.
Books a slot on one of the authenticated user's own event types, bypassing the public booking page.
The slot is claimed before the booking row is written, so a time that another booking already occupies is refused with 409 rather than double-booked. Claiming happens only for statuses that occupy a slot (confirmed, rescheduled, pending, payment_pending).
What this endpoint does not do: it does not send confirmation email, create a calendar event, provision a conferencing link, schedule reminders, or fire booking.created webhooks. Those belong to the public booking flow. A booking created here has meetingUrl: null and attendeeTimezone recorded as UTC, and guest emails are always empty.
The 201 body echoes the startTime and endTime as you supplied them, not as they were normalised and stored. Read the booking back if you need the stored form.
Hold-gate requirements. If the event type has a requirement whose gate is hold, this endpoint refuses with 409 unless you send allowUnpaidHold: true. The public booking page has no such escape hatch: a hold is a real seat out of availability, and the gate exists so a stranger cannot take one on a prerequisite only you can satisfy.
Request body (required)
| Field | Type | Description |
|---|---|---|
| eventTypeId * | string | Must be an active, non-deleted event type owned by the authenticated user. |
| startTime * | string | Meeting start. Anything the runtime can parse as a date-time; normalised to UTC before it is stored. |
| endTime * | string | Meeting end. Normalised to UTC before it is stored. |
| attendeeName * | string | |
| attendeeEmail * | string | |
| notes | string | Free text stored as the booking's attendee notes. |
| status | string | Status to create the booking in. Not validated — whatever string you send is stored. Omit it to get pending when the event type requires confirmation and confirmed otherwise. |
| allowUnpaidHold | boolean | Create the booking even though the event type has a requirement at the hold gate. Without it the request is refused with 409, the same as the public booking page. The seat is genuinely taken and the requirement stays outstanding, so the booking is held and not confirmed; the credential that sent this is recorded on the booking as the one that authorized it. |
{
"eventTypeId": "et_intro30",
"startTime": "2026-09-14T15:00:00Z",
"endTime": "2026-09-14T15:30:00Z",
"attendeeName": "Grace Hopper",
"attendeeEmail": "grace@example.com",
"notes": "Wants to discuss the pilot."
} Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | Booking created. |
| 400 | Error | A required field is missing, the email is not valid, or a time could not be parsed. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No active, non-deleted event type with that id belongs to the authenticated user. |
| 409 | Error | The slot could not be claimed — something else holds that time. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PATCH /api/v1/bookings/{id}
Update a booking's status or notes
Requires the bookings:write scope.
Changes the status and/or the attendee notes of one of the authenticated user's own bookings. Send at least one of the two fields.
The status write is conditional on the status you last read: if the booking moved underneath you, the update is refused with 409 rather than applied on top of a state you did not see.
Cancelling through this endpoint carries the same side effects as cancelling in the dashboard. Moving a booking to canceled releases its slot, deletes the linked Google, Outlook, Zoom and CalDAV entries (best effort — provider failures are logged, not surfaced), cancels pending scheduled emails, and sends the attendee the cancellation email if email is configured and the template is enabled.
Status transitions fire webhooks: booking.canceled, booking.confirmed or booking.no_show for the new status, and additionally booking.no_show_cleared whenever the booking is moving off no_show.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| status | "confirmed" | "canceled" | "pending" | "no_show" | The only four values this endpoint accepts. Anything else is a 400. |
| attendeeNotes | string or null | Replaces the notes. An empty string clears them. |
{
"status": "canceled"
} Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Booking updated. |
| 400 | Error | The status is not one of the four allowed values, or neither field was supplied. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 409 | Error | Either the slot this status change would occupy is already taken, or the booking's status changed between your read and this write. Re-read and retry. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"success": true,
"id": "bkg_4c21"
}POST /api/v1/bookings/{id}/no-show
Mark an attendee as a no-show
Requires the bookings:write scope.
Records that the attendee did not show up, moving the booking to no_show. The request takes no body — the booking is named in the path.
Only a confirmed booking whose start time has already passed can be marked: a no-show is an observation about a meeting that happened, so a booking in any other status, or one still in the future, is refused with 400.
This is the same operation the dashboard performs, and it is deliberately not the same as PATCH /api/v1/bookings/{id} with status: no_show. It fires the booking.no_show webhook *and* runs the host’s booking.no_show automation rules, exactly as a mark made in the dashboard does.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | NoShowResult | The booking is now marked as a no-show. |
| 400 | Error | The booking is not confirmed, or its start time is still in the future. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"success": true,
"id": "bkg_4c21",
"status": "no_show"
}DELETE /api/v1/bookings/{id}/no-show
Clear an attendee’s no-show mark
Requires the bookings:write scope.
Takes a no-show mark back, returning the booking to confirmed. A booking not currently marked no_show is refused with 400.
Clearing is its own operation rather than a plain status write because it is its own event to a subscriber: it fires booking.no_show_cleared, so whatever acted on the original booking.no_show learns the mark was withdrawn. Automations do not re-run — there is no un-sending an email an automation already sent.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | NoShowResult | The no-show mark has been withdrawn. |
| 400 | Error | The booking is not marked as a no-show. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"success": true,
"id": "bkg_4c21",
"status": "confirmed"
}POST /api/v1/bookings/{id}/retime-link
Mint a re-time link
Requires the bookings:write scope.
For a released hold whose link expired, or one you want to hand over another way. Minting replaces any earlier link for that booking, and nothing is emailed — you decide how it reaches the person.
Only a hold the release rule ended can be re-timed. A booking you cancelled yourself gets a one-off scheduling link instead.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Request body
| Field | Type | Description |
|---|---|---|
| expiresInHours | integer | Defaults to the event type's retimeWindowHours, or 336 when that is 0. |
Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | The link and when it expires. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 409 | Error |
|
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
Availability
Bookable slots for one event type on one date.
GET /api/v1/availability/{slug}
List bookable slots for one date
Requires the availability:read scope.
Bookable start/end pairs for one event type on one calendar date, in UTC.
Slots account for the owner's weekly availability rules (or that date's overrides, which take precedence), existing bookings, the event type's buffers, minimum notice and slot interval, and the owner's booking-frequency caps. An empty slots array is a normal answer: it means the date is fully booked, capped, overridden closed, or has no rules for that weekday.
Results are cached briefly per slug and date.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| slug * | path | string | Event type slug. Must be active, not deleted, and owned by the authenticated user. |
| date * | query | string | The date to check, YYYY-MM-DD. Rejected with 400 if absent or not that exact shape. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Bookable slots for that date. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The event type exists but belongs to a different user, or the credential lacks the |
| 404 | Error | No active, non-deleted event type has that slug. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"slots": [
{
"start": "2026-09-14T15:00:00.000Z",
"end": "2026-09-14T15:30:00.000Z"
},
{
"start": "2026-09-14T16:00:00.000Z",
"end": "2026-09-14T16:30:00.000Z",
"best": true
}
]
}Contacts
People who have booked with this user.
GET /api/v1/contacts
List contacts
Requires the contacts:read scope.
The authenticated user's own contacts, newest first.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| search | query | string | Case-insensitive substring match against name, email or company. % and _ in the value are SQL LIKE wildcards and are not escaped. |
| tag | query | string | Only contacts carrying this tag. Matched against the stored JSON tag list, so the match is on the whole tag, not a fragment of one. |
| page | query | integer | 1-based page number. Values below 1 and unparseable values fall back to 1. |
| limit | query | integer | Rows per page. Clamped to 1–100; unparseable values fall back to 50. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | A page of contacts. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
One-Off Links
Single-use scheduling links minted for one invitee.
GET /api/v1/one-off-links
List one-off scheduling links
Requires the scheduling_links:read scope.
The authenticated user's own one-off links, newest first.
Not paginated. The response is capped at the 100 most recent links and there is no page, limit or total.
Known behaviour: a link whose event type has since been soft-deleted is still listed here. The link itself stops working — booking it returns 404 — but it remains in this listing.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| event_type_id | query | string | Only links for this event type. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Up to 100 links. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/one-off-links
Mint a one-off scheduling link
Requires the scheduling_links:write scope.
Creates a scheduling link that stops accepting bookings after max_uses bookings — one, by default. Built for post-payment automation: mint a link for a single customer, send it to them, and let it retire itself.
Identify the event type by event_type_id or event_type_slug; send at least one. Either way it must be an active, non-deleted event type owned by the authenticated user.
The 201 body is deliberately narrow: it does not repeat the raw token, the use count, or the creation timestamp. Call GET /api/v1/one-off-links for the full record.
Request body (required)
| Field | Type | Description |
|---|---|---|
| event_type_id | string | |
| event_type_slug | string | Used only when event_type_id is absent. |
| label | string | Free text to identify the link in listings. Not shown to the invitee. |
| expires_at | string | When the link stops working. Must parse as a date and be in the future. |
| max_uses | integer | How many bookings the link accepts before it is spent. |
{
"event_type_slug": "onboarding-call",
"label": "Order 10482",
"max_uses": 1
} Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | Link created. |
| 400 | Error | Body was not JSON, neither identifier was supplied, |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No active, non-deleted event type matching that id or slug belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"id": "9f2c41ab7d5e4c0f8b1a",
"url": "https://acme.calemander.com/book/6f1d7c22-0b3e-4a91-9a4c-1f0e8b2d3c44",
"label": "Order 10482",
"expires_at": null,
"max_uses": 1
}Webhooks
Subscriptions that deliver booking events to your endpoint.
GET /api/v1/webhooks
List webhook subscriptions
Requires the webhooks:read scope.
Subscriptions the authenticated user can see: their own, plus — if they are a workspace admin — every organization-scoped subscription in the workspace regardless of who created it. Newest first.
Not paginated: every visible subscription is returned in one array.
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Visible subscriptions. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/webhooks
Create a webhook subscription
Requires the webhooks:manage scope.
Subscribes an HTTPS endpoint to one or more events.
The signing secret is returned only here, on creation. It is never included in any later response, and there is no way to read it back or rotate it — to change it, delete the subscription and create another.
There is no update endpoint by design. Delete and recreate.
Request body (required)
| Field | Type | Description |
|---|---|---|
| url * | string | Where deliveries are POSTed. Must be https: — the only exception is a loopback address (localhost, 127.0.0.1, [::1]), which may use http: for local development. |
| events * | array of WebhookEventName | Events to receive. Every entry must be a known event name; one unknown name rejects the whole request. |
| scope | "user" | "organization" | user fires only for bookings this user hosts. organization fires for every host in the workspace, and may only be created by a workspace admin. |
| payload_format | "calemander" | "calendly" | The wire format this endpoint receives. See the webhooks section for both shapes. |
{
"url": "https://hooks.example.com/calemander",
"events": [
"booking.created",
"booking.canceled",
"booking.rescheduled"
],
"scope": "user",
"payload_format": "calemander"
} Responses
| Status | Body | Description |
|---|---|---|
| 201 | WebhookSubscription & object | Subscription created. This is the only response that carries |
| 400 | Error | Body was not JSON, |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential lacks the |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"id": "wh_123",
"url": "https://hooks.example.com/calemander",
"events": [
"booking.created",
"booking.canceled",
"booking.rescheduled"
],
"scope": "user",
"payload_format": "calemander",
"is_active": true,
"created_at": "2026-08-29T09:14:22Z",
"secret": "0f1c9d6a-8b52-4c1e-9f7a-2d3b4c5e6f70"
}GET /api/v1/webhooks/{id}
Fetch one webhook subscription
Requires the webhooks:read scope.
A subscription the authenticated user manages: their own, or — for a workspace admin — any organization-scoped one.
A subscription that exists but that this user may not manage returns 404, the same as one that does not exist.
This response omits payload_format, which the list and create responses both include. Read it from GET /api/v1/webhooks if you need it. The signing secret is never returned here.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Subscription id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | WebhookSubscriptionDetail | The subscription. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such subscription, or it is not one this user manages. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
DELETE /api/v1/webhooks/{id}
Delete a webhook subscription
Requires the webhooks:manage scope.
Removes the subscription permanently. Same visibility rule as the fetch: a subscription this user does not manage returns 404.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Subscription id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Deleted. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such subscription, or it is not one this user manages. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
{
"success": true
}Theme
The booking page's appearance, and the proposals that come from reading a website.
GET /api/v1/theme
Read the booking page theme
Requires the theme:read scope.
The six structured theme columns for the authenticated user. custom_css is never returned.
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The current theme. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PATCH /api/v1/theme
Change the booking page theme
Requires the theme:write scope.
Writes only the fields you send; anything absent keeps its stored value. The merged result is validated the same way the dashboard validates it, so a font outside the catalog, a malformed color or a radius above 32px is refused.
A body containing custom_css is refused with 400 and code: custom_css_not_writable_via_api — never silently dropped.
Request body (required)
| Field | Type | Description |
|---|---|---|
| theme_preset | string | Panel preset: glass, glass-dark, minimal, colorful or dark. |
| accent_color | string | Button and link color, #rrggbb. |
| font_family | string | A font from the catalog. Any other name is refused. |
| border_radius | string | Corner radius, 0px to 32px. |
| background_type | string | gradient, solid, preset, image or video. |
| background_value | string | The color, preset name or URL the type calls for. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The theme as it now is. |
| 400 | — | Invalid field, or |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/theme/match
Read a website and propose a theme from it
Requires the theme:write scope.
Fetches the page at url and its stylesheets, and returns colors, a font, a button shape and a logo candidate, stored as a proposal you can apply. Nothing changes until you apply it.
The read is bounded: five a minute and forty a day per workspace, shared with the dashboard. A read that fails is not an error — the 200 body carries ok: false and a fetch_note saying whether the address was refused, unreachable or blocked.
The site is fetched with no cookies, no credentials and no headers of yours, and nothing read from it becomes CSS.
Request body (required)
| Field | Type | Description |
|---|---|---|
| url * | string | The public https address of the website to read. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | A proposal, or a note saying why the site could not be read. |
| 400 | Error | No url, or one too long to be a website address. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
GET /api/v1/theme/proposals
List theme proposals
Requires the theme:read scope.
This workspace's own match history, newest first. Summaries only; read one proposal for its palette and fields.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| page | query | integer | 1-based page number. Values below 1 and unparseable values fall back to 1. |
| limit | query | integer | Rows per page. Clamped to 1–100; unparseable values fall back to 50. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | One page of history. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
GET /api/v1/theme/proposals/{id}
Read one theme proposal
Requires the theme:read scope.
The stored palette and proposal. A proposal belonging to another user reads as 404.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | The proposal id, from a match or from the history list. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The proposal as stored. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such proposal for this user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/theme/proposals/{id}/apply
Apply a theme proposal
Requires the theme:write scope.
Writes the accepted fields to the booking page and snapshots what was there, so the change can be reverted exactly. Send fields to accept some of them; omit it to accept every field the proposal carries.
overrides.custom_css is refused with 422 and code: custom_css_not_writable_via_api. A proposal never carries custom CSS and this path cannot write it.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | The proposal to apply. |
Request body
| Field | Type | Description |
|---|---|---|
| fields | array of string | Proposal field names to accept, e.g. accent_color. Omit to accept all of them. |
| overrides | object | Your own values, applied over the proposal. Theme fields only. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The theme as it now is. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 409 | Error | The proposal was already applied. |
| 422 | — | The proposal cannot be applied, or |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/theme/proposals/{id}/revert
Undo an applied theme proposal
Requires the theme:write scope.
Restores the theme snapshot taken when the proposal was applied, exactly as it was, and marks the proposal reverted. Only an applied proposal can be reverted.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | The applied proposal to undo. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The restored theme. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 409 | Error | The proposal was never applied, or has no snapshot. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/theme/preview-link
Mint a theme preview link
Requires the theme:write scope.
Reads a website and returns a signed, short-lived link showing a demo workspace's real service menu in that site's colors, watermarked as an example. Nothing is stored and no booking made on it is real.
The read costs one unit from the match budget and one from the preview budget. A deployment with no demo workspace answers 503.
Request body (required)
| Field | Type | Description |
|---|---|---|
| url * | string | The public https address of the website to read. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | A preview link and when it stops working. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 422 | — | The site could not be read. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
| 503 | Error | No demo workspace is configured. |
Requirements
The prerequisites a booking is held on, and the clears that release it.
POST /api/v1/checkout-claims
Mint a reservation token for your own checkout
Requires the bookings:write scope.
Asks for a short-lived, signed token that lets ONE customer reserve ONE time on ONE event type from inside your own checkout.
Call it server-side, when the customer reaches your checkout and you are about to show them the time picker. Put the returned embedUrl in an iframe; when the customer picks a time the booker reserves it immediately — off your calendar, no email, no webhook — and posts calemander:claimCreated to your page with the booking id to carry on your payment.
The event type must carry a requirement marked as the one your checkout reserves against ("Payment is collected on my site"), and must not also carry a Calemander price, deposit or card-on-file setting: two rails charging one booking is two sources of truth for the same money.
claimTtlSeconds is how long the RESERVATION lasts once it is made — set it to the life of your own payment session. A reservation that outlives the session holds inventory nobody can buy; one that dies before it produces 410s when the payment finally lands. tokenTtlSeconds is the different, shorter clock on how long this token may be used to START a reservation.
Bind attendeeEmail whenever you know it: it is what stops a token leaked out of one customer's browser reserving a time for somebody else. Do not log the token, and do not mint one before you know the customer is on the page.
Request body (required)
| Field | Type | Description |
|---|---|---|
| eventTypeId * | string | The event type the customer is buying. Must carry a checkout requirement. |
| requirementKey | string | The checkout requirement's key. Optional; refused if it is not the one this event type carries. |
| attendeeEmail | string | Bind the token to one address. The booking must then be made with it. |
| claimTtlSeconds | integer | How long the reservation lasts once made, 300–7200. Default 1800. |
| tokenTtlSeconds | integer | How long this token may be used to start a reservation, 300–3600. Default 1800. |
Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | The token, its expiry, and the embed URL to mount. |
| 400 | Error | The event type has no checkout requirement, carries a Calemander price, or a TTL is out of range. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such event type in this workspace. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/external-payment
Record a payment you took on your own site
Requires the requirements:write scope.
Tells Calemander that the payment for a reserved time cleared. Call it from your payment webhook, or wherever you learn the money arrived.
Calemander charged nobody: it records "reported paid by <provider> under <reference>", marks the booking paid, fires booking.created at THIS moment — not when the time was reserved — and then either confirms the booking or holds it on whatever else you require. provider is a label of your own choosing (stripe, square, invoice, manual, anything); nothing branches on it.
Send an Idempotency-Key. Your provider will deliver its webhook more than once, and with the key the second delivery gets the first answer verbatim, status code included, with nothing running twice.
A 410 means the reservation ended before the payment arrived and the time may already be sold. The payment is still recorded, so the audit trail is honest; Calemander does not resurrect the booking and never refunds anything. What to tell the customer is yours to decide — the pattern that works is a one-off scheduling link and one true sentence.
The generic requirement clear endpoint refuses the checkout requirement of a live reservation with 409 record_payment_instead: "paid" is a money fact with a provider, an amount and a currency, and it belongs on the booking.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id — the one the booker gave you when the time was reserved. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| provider * | string | Your label for who took the money. 1–40 characters of lowercase letters, digits, hyphens or underscores. |
| reference * | string | Your own reference for the payment, 1–200 characters. |
| amountCents | integer | What was paid, in the event type's currency. |
| currency | string | Must match the event type's currency, or be omitted. |
| receiptUrl | string | A link a person can follow to the receipt. |
| paidAt | string | When the payment cleared, ISO-8601. |
| note | string | A line for the host. Never shown to the customer. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Recorded, or already recorded. |
| 400 | Error | The body cannot be acted on: no provider or reference, a bad amount, or the wrong currency. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No reservation with that id in this workspace, or the booking was not made through your checkout. |
| 409 | Error | The same payment is already being recorded under this Idempotency-Key. |
| 410 | Error | The reservation ended before the payment arrived. Recorded, not resurrected, not refunded. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/release-claim
Give a reservation back
Requires the bookings:write scope.
Puts a reserved time back on sale now, rather than waiting for it to expire.
Optional: a customer who picks again replaces their own reservation, and an unpaid one expires on its own. Use this when your checkout KNOWS the customer left.
released: false is not an error — it means the reservation was already gone, or already paid for. A payment landing in the same instant wins, and the time stays sold.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id of the reservation to give back. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | Whether this call was the one that released it. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such booking in this workspace. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
GET /api/v1/bookings/{id}/requirements
List what a booking is still waiting on
Requires the requirements:read scope.
The booking’s requirement checklist in the order an attendee reads it, with the full provenance of everything already satisfied.
Only cleared and waived count as met. A booking whose outstanding is 0 is confirmed; one with rows still open is held — its time is taken out of availability, and there is no calendar event, no reminder and no confirmation email until the last requirement clears.
A booking with an empty list is not a requirements booking and behaves exactly as it always has.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | BookingRequirementList | The booking’s requirements. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/requirements/{key}/clear
Clear a requirement
Requires the requirements:write scope.
Records that a prerequisite has been met, from the system that actually knows. If it was the last one, the booking confirms itself: calendar event, confirmation email, reminders, and the booking.confirmed webhook, with nobody clicking anything.
Send externalRef: the identifier your system matched on. How the clear is classified is not yours to send. There is no clearMethod field and there must not be — a caller that could name its own method could dress a guess up as a county’s reference. A requirement that was outstanding records system_reference; one the attendee had already submitted records attendee_evidence with verifiedBy: "api", and referenceMatched is true when your reference is the one they typed.
Clearing an already-cleared requirement is a 200 no-op: the answer is already yes. Clearing on a canceled or released booking is also a 200 — the clear is recorded, the booking is not resurrected, and the response says what the booking’s status actually is.
Idempotency. Send an Idempotency-Key header and the write runs once: a replay with the same key returns the first response verbatim rather than acting again, so a retry loop cannot fire booking.confirmed twice. A clear carrying an externalRef that already sits on a cleared requirement of the same booking is a no-op regardless of the header.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
| key * | path | string | The requirement’s key, as defined on the event type. |
Request body
| Field | Type | Description |
|---|---|---|
| externalRef | string | The identifier your system matched on — a county application number, a policy number, a certificate id. |
| evidenceUrl | string | A link your system can supply for a person who wants to look at the underlying record. |
| note | string | A line for the host. Never shown to the attendee. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | RequirementWriteResult | The requirement’s state after the call, and the booking’s. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such booking for this credential, or the booking has no requirement with that key. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/requirements/{key}/waive
Waive a requirement
Requires the requirements:write scope.
Decides that this booking does not need the prerequisite after all. Waiving satisfies, so it can be the move that confirms the booking.
note is required. A waiver is a person overriding a gate, and a waiver with no reason is an unexplained hole in the record — it is the one line a host reading the booking six weeks later needs. The note is host-facing and is never rendered to the attendee.
Idempotency. Send an Idempotency-Key header and the write runs once: a replay with the same key returns the first response verbatim rather than acting again, so a retry loop cannot fire booking.confirmed twice. A clear carrying an externalRef that already sits on a cleared requirement of the same booking is a no-op regardless of the header.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
| key * | path | string | The requirement’s key, as defined on the event type. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| note * | string | Why this booking does not need it. Required. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | RequirementWriteResult | The requirement is waived. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such booking for this credential, or the booking has no requirement with that key. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/requirements/{key}/verify
Verify what the attendee submitted
Requires the requirements:write scope.
Says yes to a reference the attendee supplied themselves. Only a submitted requirement can be verified; anything else is a 409 naming its state, because a caller that meant to verify and instead cleared an untouched requirement would have recorded something stronger than what happened.
The attendee submitting a reference satisfies nothing on its own: it is a claim about the outside world, and a booking must never confirm on a claim. This is the second party saying the claim is true. Send your own externalRef and referenceMatched comes back true when it is the same one they typed — the strongest signal the system holds. When the two disagree the requirement still clears and referenceMatched stays false: a person can mistype while the outside system approves the right file, and a hard block there would hold a correct booking hostage to a typo. It is surfaced, not enforced.
Idempotency. Send an Idempotency-Key header and the write runs once: a replay with the same key returns the first response verbatim rather than acting again, so a retry loop cannot fire booking.confirmed twice. A clear carrying an externalRef that already sits on a cleared requirement of the same booking is a no-op regardless of the header.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
| key * | path | string | The requirement’s key, as defined on the event type. |
Request body
| Field | Type | Description |
|---|---|---|
| externalRef | string | The identifier your system matched on, compared against what the attendee typed. |
| note | string | A line for the host. Never shown to the attendee. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | RequirementWriteResult | The submitted reference is verified and the requirement is cleared. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such booking for this credential, or the booking has no requirement with that key. |
| 409 | Error | The requirement is not in the |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/bookings/{id}/requirements/{key}/send-back
Send an attendee’s submission back
Requires the requirements:write scope.
Rejects a reference the attendee supplied. The requirement moves to failed, which does not satisfy, so the booking stays held — and the host sees why, instead of an outstanding row that looks like nobody has looked at it.
note is required and is written for the host; it is never shown to the attendee. Sending back also invalidates the attendee’s outstanding self-serve links, so they open a fresh one and retype. The ordinary cause of a rejection is a mistyped digit, and that should cost one email rather than a support ticket.
Idempotency. Send an Idempotency-Key header and the write runs once: a replay with the same key returns the first response verbatim rather than acting again, so a retry loop cannot fire booking.confirmed twice. A clear carrying an externalRef that already sits on a cleared requirement of the same booking is a no-op regardless of the header.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
| key * | path | string | The requirement’s key, as defined on the event type. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| note * | string | What was wrong with it. Required, and host-facing only. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | RequirementWriteResult | The submission has been sent back. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such booking for this credential, or the booking has no requirement with that key. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/requirements/preclear
Record a clear that arrived before the booking
Requires the requirements:write scope.
Files a clear against a person rather than a booking, for when the outside system knows before the attendee has picked a time.
The case this exists for: an approval lands while the customer has still not booked. There is no requirement row to clear, and asking your integration to poll until one appears would trade a fact it already holds for a schedule. Post it here instead. When a booking is later created for a matching subject, the requirement is written already cleared, carrying this call’s provenance, and the booking confirms on the spot.
subject needs at least one of attendeeEmail or externalRef — a pre-clearance naming nobody would not mean "this customer is cleared", it would mean "clear the next booking that arrives". A pre-clearance is single-use and expires (180 days unless the requirement’s definition says otherwise), because an approval about one file should not confirm a second booking, and should not still be confirming bookings after the thing it attested to has lapsed.
Idempotency. Send an Idempotency-Key header and the write runs once: a replay with the same key returns the first response verbatim rather than acting again, so a retry loop cannot fire booking.confirmed twice. A clear carrying an externalRef that already sits on a cleared requirement of the same booking is a no-op regardless of the header.
Request body (required)
| Field | Type | Description |
|---|---|---|
| key * | string | The requirement key this clears. |
| eventTypeId | string | Limit it to one event type. Omit to clear the key on any event type in the workspace that defines it. |
| subject * | object | Who the clear is about. At least one field is required. |
| externalRef | string | The identifier your system matched on. Defaults to the subject’s reference. |
| evidenceUrl | string | A link to the underlying record. |
| note | string | A line for the host. Never shown to the attendee. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | Preclearance | A replay of the same |
| 201 | Preclearance | The pre-clearance is on file and waiting for a booking. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No event type with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
GET /api/v1/event-types/{id}/requirements
Read an event type’s requirements
Requires the requirements:read scope.
The prerequisites this event type asks for. Each one is copied onto every booking made on it, so editing the wording here changes what future attendees are asked and leaves what past attendees agreed to exactly as it was.
Host approval is not listed: it is the event type’s own "requires confirmation" setting, and giving it a second home here is how the two would come to disagree.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | EventTypeRequirementList | The event type’s requirement definitions. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No event type with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PUT /api/v1/event-types/{id}/requirements
Set an event type’s requirements
Requires the requirements:write scope.
Replaces the whole list. A definition that is absent from the array is deactivated rather than deleted — bookings already held on it keep their own copy of the wording, and deleting the template would strand them.
An empty array clears them. host_approval is refused as a key. allowAttendeeEvidence is only valid on an external requirement, and an attendee_acknowledgement needs the ackText the attendee is agreeing to.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| requirements * | array of EventTypeRequirement | The complete list, in the order attendees should read it. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | EventTypeRequirementList | The event type’s requirements as they now stand. |
| 400 | Error | The request body is not valid. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No event type with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
Questions
Questions an event type asks, and the answers a booking carries. A question is asked at booking or after it; an after-booking question is answered by the attendee on their own booking page and never holds anything up.
GET /api/v1/event-types/{id}/questions
List an event type’s questions
Requires the event_types:read scope.
Both stages by default, in the order they are shown. Pass ?stage=after_booking to list only the questions the attendee answers on their own booking page, or ?stage=at_booking for the booking form's.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
| stage | query | "at_booking" | "after_booking" |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | QuestionList | The event type’s questions. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No event type with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
POST /api/v1/event-types/{id}/questions
Add a question to an event type
Requires the event_types:write scope.
stage defaults to at_booking, which is the booking form. Set it to after_booking and the question never appears on the form; the attendee answers it on their booking page, whenever they are ready, and isRequired then means one reminder rather than a gate.
A condition may only point backwards in stage: a booking-form question cannot depend on an after-booking answer, because that answer does not exist when the form is rendered.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
Request body (required)
| Field | Type | Description |
|---|
Responses
| Status | Body | Description |
|---|---|---|
| 201 | — | The question that was created. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No event type with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PATCH /api/v1/event-types/{id}/questions/{questionId}
Update a question
Requires the event_types:write scope.
Any subset of the create body. Moving a question to after_booking while a booking-form question depends on it is refused with condition_stage_order, which is the same rule read from the other end. Conditions name option TEXT: renaming an option rewrites every rule that named it, and removing an option another question's condition still names is refused with 422 unless the body sets detachConditions to true.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
| questionId * | path | string | Question id. |
Request body (required)
| Field | Type | Description |
|---|
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The updated question. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such question on an event type belonging to the authenticated user. |
| 422 | Error |
|
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
DELETE /api/v1/event-types/{id}/questions/{questionId}
Remove a question
Requires the event_types:write scope.
A soft delete: the question stops being asked, and every answer already given to it stays readable on the bookings that carry it. A question that another question's condition depends on is refused with 422; pass ?detachConditions=true to drop those rules and delete it anyway.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Event type id. |
| questionId * | path | string | Question id. |
| detachConditions | query | boolean | Set to true to drop the conditions that depend on this question instead of refusing with 422. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | — | The question is no longer asked. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No such question on an event type belonging to the authenticated user. |
| 422 | Error |
|
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
GET /api/v1/bookings/{id}/responses
Read a booking’s answers
Requires the bookings:read scope.
Every question the event type asks, answered or not, in the order they are shown. A question with answer: null has not been answered; answeredAt is null for an answer given on the booking form.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | BookingResponseList | The booking’s answers. |
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error | No booking with that id belongs to the authenticated user. |
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
PUT /api/v1/bookings/{id}/responses
Write a booking’s answers
Requires the bookings:write scope.
A merge: only the questions named in the body change, and null clears an answer. Values are validated against each question's type and options exactly as the booking form validates them. Any stage may be written — correcting a typo in a booking-form answer is legitimate — and the write is recorded as answered by api.
Idempotent by construction, so no Idempotency-Key is needed: sending the same body twice leaves the same answers and fires one booking.responses_updated, because the second save changes nothing.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| id * | path | string | Booking id. |
Request body (required)
| Field | Type | Description |
|---|---|---|
| responses * | array of object |
Responses
| Status | Body | Description |
|---|---|---|
| 200 | BookingResponseList | The full answer set after the merge. |
| 400 | Error |
|
| 401 | Error | No credential was presented, or it did not resolve. |
| 403 | Error | The credential resolved but lacks the scope this operation requires. The message names the missing scope. |
| 404 | Error |
|
| 409 | Error |
|
| 429 | RateLimitError | More than 60 requests in the current 60-second window. Note the body shape differs from every other error. |
| 500 | Error | The workspace's database or cache binding was not available to serve the request. |
Webhook events
These are the events Calemander sends to you. Subscribe with POST /api/v1/webhooks; the signing secret is returned once, on creation.
booking.canceled
A booking was cancelled
Fires when a booking is cancelled by the invitee, by the host, as part of cancelling a recurring series, when a pending booking is rejected, or through PATCH /api/v1/bookings/{id}. canceledBy and cancellationReason are populated where they were recorded.
booking.checked_in
An attendee was checked in
Fires when a confirmed booking is checked in to its session — by staff on the roster, by the attendee at the door or from their phone, or by a walk-in booking. The booking keeps status: confirmed; the payload adds checkedInAt and checkedInBy (staff, self, door or auto). Undoing a check-in sends nothing.
booking.confirmed
A pending booking was approved
Fires when a booking that required confirmation is approved by the host, when payment completes for a paid event type, or when PATCH /api/v1/bookings/{id} moves a booking to confirmed.
Calendly-format subscribers never receive this event — Calendly has no approval step and therefore no equivalent name, so nothing is sent rather than something the receiver cannot dispatch.
booking.created
A booking was made
Fires when a booking is created through the public booking flow, including recurring series. It does not fire for bookings created through POST /api/v1/bookings.
booking.held
A booking is held on unmet requirements
Fires when a booking takes a time but cannot be confirmed yet: its seat is out of availability and one or more of its requirements is neither cleared nor waived. The payload carries the requirement list.
There is no calendar event and no confirmation email at this point, and the attendee has been told so. booking.confirmed follows when the last requirement clears.
Calendly-format subscribers never receive this event — Calendly has no held booking and therefore no equivalent name, so nothing is sent rather than something the receiver cannot dispatch.
booking.no_show
An invitee was marked as a no-show
booking.no_show_cleared
A no-show mark was taken back
Fires when a booking leaves the no_show status. When the change also produces another event — a no-show booking moved straight to canceled, say — both are delivered.
booking.rescheduled
A booking moved to a new time
Fires when a booking is rescheduled. The payload carries the new startTime/endTime and the previous ones as oldStartTime/oldEndTime; status is confirmed.
On the calendly format this single event becomes two deliveries, matching what Calendly itself sends: an invitee.canceled for the old time with rescheduled: true, followed by an invitee.created for the new time carrying old_invitee.
{
"event": "booking.rescheduled",
"payload": {
"eventTypeId": "et_intro30",
"eventTypeSlug": "intro-30",
"eventTypeName": "Intro call",
"durationMinutes": 30,
"locationType": "google_meet",
"meetingUrl": "https://meet.google.com/abc-defg-hij",
"attendeeNotes": null,
"attendeeTimezone": "Europe/London",
"host": {
"name": "Ada Lovelace",
"email": "ada@example.com"
},
"answers": [],
"bookingCreatedAt": "2026-08-20T11:02:00Z",
"canceledBy": null,
"cancellationReason": null,
"paymentStatus": null,
"guestEmails": [],
"joinUrl": "https://ada.calemander.com/join/bkg_4c21/eyJ2IjoxLCJ0Ijo...",
"oneOffLinkId": null,
"oneOffToken": null,
"bookingId": "bkg_4c21",
"eventSlug": "intro-30",
"eventName": "Intro call",
"startTime": "2026-09-15T15:00:00Z",
"endTime": "2026-09-15T15:30:00Z",
"oldStartTime": "2026-09-14T15:00:00Z",
"oldEndTime": "2026-09-14T15:30:00Z",
"attendeeName": "Grace Hopper",
"attendeeEmail": "grace@example.com",
"status": "confirmed"
},
"timestamp": "2026-08-29T09:14:22.115Z"
}requirement.cleared
A requirement was satisfied
Fires when one requirement moves to cleared or waived. The payload carries the full provenance — clearMethod, clearedBy, verifiedBy, referenceMatched, externalRef and evidenceRef — so a subscriber can hold a weakly-cleared booking to a different standard than a strongly-cleared one without a second API call.
It fires once per actual move. A replay, an already-cleared requirement and a repeated reference all change nothing and send nothing. When the clear was the last one, booking.confirmed follows.
Calendly-format subscribers never receive this event.
requirement.submitted
An attendee supplied a reference
Fires when an attendee submits evidence for a requirement — the county application number they are holding, say. The requirement moves to submitted and the booking stays held: this is a claim about the outside world, not a fact about it.
This is the event an integration subscribes to in order to close the loop by itself. Your system hears the reference the moment the attendee types it, can go and match on it, and can then clear the requirement — at which point the two independent sources agree and the record says so.
Calendly-format subscribers never receive this event.
booking.released
A held booking was released
Fires when a booking held on outstanding requirements reaches its release time with something still uncleared. The booking becomes status: canceled with cancellation_reason naming what was outstanding, and the seat goes back on sale. The payload adds outstanding (the labels still unmet), releasedAt, rebookUrl, and refundIssued, which is always false — releasing a hold never refunds, and whether money is returned is the host's decision rather than this event's. A requirement that clears after this fires is recorded on the released booking and does not un-cancel it.
booking.responses_updated
An after-booking answer changed
Fires whenever an answer to a question the event type asks after booking actually changes — from the attendee's own booking page or from PUT /bookings/{id}/responses. Never fires for a save that changed nothing. The payload adds changedQuestionIds (the ids that moved), answeredBy (attendee, host or api), responses (the full set for the booking, both stages, each with stage and answeredAt), and detailsUrl. There is no Calendly-compatible name for this event, so compat subscribers do not receive it. A required after-booking question never changes a booking's status, so this event never coincides with one.
routing_form_submission.created
A routing form was submitted
Fires when someone submits a routing form. Submissions are not stored, so submissionId identifies this delivery rather than a row you can fetch later. This payload receives none of the booking enrichment fields.
standby.offered
A freed seat was offered to someone on standby
Fires when a seat comes free on a full class and the person at the front of the standby line is offered it. One person per seat: nobody else is offered the same seat while this offer is open. offerExpiresAt is when the hold ends, which is the event type's standbyOfferMinutes or five minutes when the class starts within 30. An offer that runs out sends nothing; the entry goes back to the end of the line.
standby.promoted
Someone on standby got the seat
Fires when a standby entry becomes a booking — they accepted an offer, the host promoted them from the roster, or a close-out gave them a seat freed by a no-show. bookingId is the booking that now exists; the ordinary booking.created fires for it as well.
Schemas
Booking
| Field | Type | Description |
|---|---|---|
| id * | string | |
| eventTypeId * | string | |
| eventTypeName * | string or null | Null if the event type row is gone. |
| eventTypeSlug * | string or null | |
| startTime * | string | UTC, as stored. |
| endTime * | string | UTC, as stored. |
| attendeeName * | string | |
| attendeeEmail * | string | |
| attendeeNotes * | string or null | |
| attendeeTimezone * | string or null | IANA timezone the attendee booked in, e.g. Europe/London. Null on rows recorded before the column existed. |
| status * | string | Values in use: confirmed, pending, canceled, rescheduled, payment_pending, no_show. |
| meetingUrl * | string or null | |
| paymentStatus * | string or null | Null for free event types. |
| guestEmails * | array of string | Additional invitees. Empty when there are none. |
| createdAt * | string | |
| detailsUrl | string or null | The attendee's booking page, when the event type asks anything after booking. Null otherwise. Put it in your own receipt when you have turned Calemander's held email off. |
| responsesUpdatedAt | string or null | When an after-booking answer on this booking last changed. Null when every answer came in with the booking. |
BookingRequirement
One prerequisite on one booking, with the full record of how it reached its state.
| Field | Type | Description |
|---|---|---|
| key | string | The host-authored, stable identifier for this requirement. |
| kind | "host_approval" | "external" | "attendee_acknowledgement" | Who can satisfy it: a workspace member, an outside system or verified attendee evidence, or the attendee ticking a statement. |
| label | string | The attendee-visible name, snapshotted when the booking was made. |
| instructions | string or null | Attendee-visible body text, snapshotted. |
| instructionsUrl | string or null | Where the attendee goes to do the thing. |
| state | "outstanding" | "submitted" | "cleared" | "waived" | "failed" | Only cleared and waived satisfy. submitted is the attendee’s claim and satisfies nothing; failed is a rejected claim and holds the booking. |
| evidenceRef | string or null | What the attendee typed, when they were allowed to supply a reference. |
| externalRef | string or null | The clearing system’s own identifier for what it matched. |
| clearedAt | string or null | |
| clearedBy | "host" | "attendee" | "api" | "system" | null | Which kind of credential cleared it. Set by the server, never by the request. |
| clearMethod | "system_reference" | "host_manual" | "attendee_evidence" | null | How it was cleared. Derived from the row’s state and the credential; there is no request field for it. |
| verifiedBy | "api" | "host" | null | On an attendee_evidence clear, which kind of verifier said yes. |
| referenceMatched | boolean | True when the clearing system’s reference is the one the attendee had already typed — two independent sources naming the same file. |
| note | string or null | Why it was waived or sent back. Written for the host and never shown to the attendee. |
| sortOrder | integer |
BookingRequirementList
| Field | Type | Description |
|---|---|---|
| bookingId | string | |
| outstanding | integer | How many requirements are still neither cleared nor waived. |
| requirements | array of BookingRequirement |
BookingResponse
| Field | Type | Description |
|---|---|---|
| questionId | string | |
| question | string | The question's label. |
| answer | string or boolean or array of string or null | The stored answer, or null when the question has not been answered. |
| stage | "at_booking" | "after_booking" | |
| answeredAt | string or null | Null for an answer given on the booking form. |
BookingResponseList
| Field | Type | Description |
|---|---|---|
| bookingId | string | |
| responses | array of BookingResponse | |
| detailsUrl | string or null | The attendee's booking page, when the event type asks anything after booking. |
BookingWebhookEnvelope
The calemander wire format: one message per event. Signed with HMAC-SHA256 over the raw body, hex-encoded, in X-Webhook-Signature; X-Webhook-Timestamp echoes the timestamp below. Subscribers on the calendly format receive Calendly's own envelope and a Calendly-Webhook-Signature: t=<unix>,v1=<hex> header instead, where v1 is HMAC-SHA256 over <t>.<raw body>.
| Field | Type | Description |
|---|---|---|
| event * | WebhookEventName | |
| payload * | BookingWebhookPayload | |
| timestamp * | string | When the event fired. Shared by every message one event produces. |
BookingWebhookPayload
The eight base fields are always present. The rest are enrichment, loaded from the booking at fire time — if that lookup fails the message still ships, carrying the base fields alone. Treat every enrichment field as optional.
| Field | Type | Description |
|---|---|---|
| bookingId * | string | |
| eventSlug * | string | |
| eventName * | string | |
| startTime * | string | UTC. |
| endTime * | string | UTC. |
| attendeeName * | string | |
| attendeeEmail * | string | |
| status * | string | |
| oldStartTime | string | The time the booking moved from. Present on booking.rescheduled only. |
| oldEndTime | string | Present on booking.rescheduled only. |
| eventTypeId | string | |
| eventTypeSlug | string or null | |
| eventTypeName | string or null | |
| durationMinutes | integer | |
| locationType | string or null | |
| meetingUrl | string or null | |
| attendeeNotes | string or null | |
| attendeeTimezone | string or null | IANA timezone the attendee booked in, e.g. Europe/London. |
| host | null or object | The host assigned to this booking — the round-robin member where one is set, the event owner otherwise. Null when no host email could be resolved. |
| answers | array of object | The invitee's answers to the event type's booking questions, in question order. |
| bookingCreatedAt | string | |
| canceledBy | string or null | |
| cancellationReason | string or null | |
| paymentStatus | string or null | |
| guestEmails | array of string | |
| joinUrl | string or null | The attendee-facing join link, <origin>/join/<bookingId>/<token> — the same link the confirmation email and the .ics carry. Show this one to people. NOT the same as meetingUrl, which is the raw provider URL: a reschedule re-mints the provider event, so meetingUrl goes stale in every copy already sent, while this link resolves the provider URL at click time and survives. Null when the booking is canceled, and when it is a phone or in-person appointment that never has a meeting to join. NOT null for a video booking whose provider event has not been created yet — that page self-refreshes while the meeting is prepared. |
| oneOffLinkId | string or null | The one-off scheduling link this booking was made through, as the stable id for GET /api/v1/one-off-links. Null when the booking came from the public booking page. |
| oneOffToken | string or null | The token from that one-off link's own URL, and the field to match on: whatever minted the link already holds this string, so it identifies the order, lead or case that issued the invitation with no email or name heuristic. Null when the booking came from the public booking page. one_off_links.booking_id holds only the latest booking through a link, so a multi-use link reports its token on its most recent booking and null on earlier ones. |
Contact
| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| email * | string | |
| phone * | string or null | |
| company * | string or null | |
| tags * | array of string | |
| source * | string | How the contact was first recorded. |
| bookingCount * | integer | |
| lastBookingAt * | string or null | |
| createdAt * | string |
Error
The error shape used by every endpoint except the 429.
| Field | Type | Description |
|---|---|---|
| message * | string | Human-readable explanation. |
EventType
| Field | Type | Description |
|---|---|---|
| id * | string | |
| name * | string | |
| slug * | string | Used in booking URLs and in the availability path. |
| duration * | integer | Meeting length in minutes. |
| maxDaysAhead | integer or null | How many days ahead this event type may be booked; null means no limit. Counted as calendar dates on the HOST's own timezone, so 30 means every date up to and including the one 30 days after the host's today. Dates past the window are not offered on the booking page, and a booking for one is refused with 409. |
| description * | string or null | |
| isActive * | boolean | |
| coverImage * | string or null | |
| inviteCalendar * | string or null | The raw stored legacy setting. Prefer locationType and calendarProvider, which are the resolved values. |
| locationType * | string or null | Where the meeting happens, resolved against the owner's defaults. |
| calendarProvider * | string or null | Which calendar the event and the attendee invite land on, resolved against the owner's defaults. |
| meetingMode * | "one_on_one" | "round_robin" | "collective" | Defaults to one_on_one when unset. |
| format * | "one_on_one" | "group_class" | "round_robin" | "collective" | The event type's shape as one value, and the field to read in preference to meetingMode + maxSeats. group_class is the only format with seats: it is what meetingMode one_on_one and maxSeats above 1 mean together. Event types written before this field existed report the format derived from that pair, so it is never absent. |
| hosts * | array of EventTypeHost | Active member hosts. Empty for one_on_one event types. |
| maxSeats * | integer or null | Seats per slot for group events; null for one-attendee events. |
| capacity * | integer or null | Seats on a group class — the same number as maxSeats, named for what it means. Null for every format other than group_class. |
| color * | string or null | The event type's #rrggbb accent colour. |
| autoCloseMinutes * | integer or null | For a group event type: minutes after start at which check-in closes on its own; null = the door is closed by hand. Always null for a one-on-one. |
| creditTiming * | "booking" | "attendance" | When a package credit is spent: at booking, or at check-in (attendance). Always booking for a one-on-one. |
| standbyEnabled | boolean | Whether a full class offers a place in line instead of a dead end. |
| standbyOfferMinutes | integer | How long one person holds a freed seat before it passes to the next. |
| redirectUrl * | string or null | Where an invitee is sent after booking, if configured. |
| redirectPassDetails * | boolean | Whether booking details are appended to redirectUrl as query parameters. |
| sendHeldNotifications * | boolean | Whether Calemander emails and texts the attendee when their time is held. Defaults to true. Set it to false when your own site tells the customer at the moment of payment, so they are not told the same thing twice by two senders. It silences only that one message: the host's own notification, the booking.held and booking.created webhooks, the release clock, the reminder that a hold is running out, the notice that a hold was released, and the confirmation email all still go, and a resend requested by a person still sends the held email. |
| priceCents * | integer or null | Price in minor units; null for free event types. |
| currency * | string | Defaults to usd. |
| createdAt * | string | |
| priceDisplay | "auto" | "hidden" | Whether the booking page shows a price row. 'auto' derives it from the price and deposit, showing 'Free' when nothing is charged. 'hidden' shows no price row anywhere — on the booking page, the embed, the sidebar or the confirmation screen — for a host who collects payment on their own site, where 'Free' would be untrue. It changes only what is displayed; nothing about what is charged, refunded or held changes. |
| policyLineOverride | string or null | The one-line cancellation policy shown under the price, in the host's own words, used verbatim. Null (the default) generates the sentence from the cancellation settings. Not applied to a card-on-file fee sentence, which discloses an amount that can be charged and must keep matching the booking row. |
| submitLabel | string or null | The submit button's text, used verbatim. Null (the default) derives it from what the event type is called to its attendee: 'Book meeting', 'Book appointment' or 'Book class'. Applied only when the click leads to no payment step; a button that continues to payment, saves a card or spends a package credit keeps its generated label so it cannot be relabelled to hide a charge. |
EventTypeHost
| Field | Type | Description |
|---|---|---|
| userId * | string | |
| name * | string | |
| email * | string | |
| weight * | number | This host's target share of round-robin assignments. |
EventTypeRequirement
A requirement as defined on an event type — the template copied onto every booking.
| Field | Type | Description |
|---|---|---|
| key | string | |
| kind | "host_approval" | "external" | "attendee_acknowledgement" | |
| label | string | |
| instructions | string or null | |
| instructionsUrl | string or null | |
| ackText | string or null | |
| allowAttendeeEvidence | boolean | |
| evidenceLabel | string or null | |
| evidenceHint | string or null | |
| preclearTtlDays | integer | |
| preclearReusable | boolean | |
| gate | "hold" | "confirm" | Which moment this requirement stands at. confirm (the default) is satisfied after the booking exists: the seat is held and clearing the last requirement confirms it. hold means no time may be taken until it is met — the public booking page refuses the booking outright rather than letting someone hold a seat on a prerequisite they cannot satisfy. Not valid on an attendee_acknowledgement, which is ticked on the booking’s own page and so cannot precede it. |
| sortOrder | integer |
EventTypeRequirementList
| Field | Type | Description |
|---|---|---|
| eventTypeId | string | |
| requirements | array of EventTypeRequirement |
EventTypeWrite
Body of POST /api/v1/event-types (where name, slug and durationMinutes are required) and PATCH /api/v1/event-types/{id} (where every field is optional and only the fields present are changed). Unknown fields are refused with 400.
These are the fields the dashboard editor writes that the API exposes. Payment settings (mode, price, deposit, fees), buffers, booking limits, questions and redirects are configured in the dashboard.
| Field | Type | Description |
|---|---|---|
| name | string | |
| slug | string | Lowercase letters, digits and hyphens; unique across the workspace. A taken slug is 400 on create and 409 on patch. |
| description | string or null | |
| durationMinutes | integer | |
| maxDaysAhead | integer or null | How many days ahead this event type may be booked, 1-365. Send null to remove the limit, which is what an event type means until one is set. Counted as calendar dates on the HOST's own timezone. Dates past the window are not offered and cannot be booked. |
| maxSeats | integer or null | Above 1 makes a group event type; null or 1 is a one-on-one (stored as null). Turning a group into a one-on-one clears autoCloseMinutes and resets creditTiming. |
| format | "one_on_one" | "group_class" | "round_robin" | "collective" | The event type's shape, as one field. Prefer it to maxSeats and meetingMode, which it sets: group_class stores capacity seats, and every other format stores one seat. Sending it alongside either of those is 400 — a body naming both axes has no single right answer. Switching away from group_class clears the seats, autoCloseMinutes and creditTiming, and switching to round_robin or collective carries the same admin and memberIds rules as meetingMode. |
| capacity | integer | Seats on a group class. Only accepted with format: "group_class" (400 otherwise). Omitted on PATCH, a class keeps the seats it already had; omitted on create it starts at 12. |
| locationType | "google_meet" | "zoom" | "teams" | "jitsi" | "phone" | "in_person" | null | Set together with calendarProvider to override the workspace default, or null on both to inherit it. |
| calendarProvider | "google" | "outlook" | "none" | null | |
| meetingMode | "one_on_one" | "round_robin" | "collective" | Changing to or from a team mode requires the credential's owner to be a workspace admin (403 otherwise); a team mode needs memberIds. |
| memberIds | array of string | Host user ids for a team mode. Every id must be a member of this workspace with a connected calendar; ignored for one_on_one. On PATCH, members left out are removed and hosts that stay keep their round-robin weight. |
| color | string or null | |
| isActive | boolean | Defaults to true on create. Switching an event type ON counts against the plan's active-event-type cap (402 when full); on create the event type is created switched off instead and the response says so. |
| autoCloseMinutes | integer or null | Group event types only. Turning auto-close on requires a plan with check-in (402 otherwise). |
| creditTiming | "booking" | "attendance" | Group event types only. Switching to attendance requires a plan with check-in (402 otherwise). |
| standbyEnabled | boolean | Group event types only. When the class is full the booking page offers a place in line, and a freed seat is offered to one person at a time, so it can never be given away twice. Turning standby on requires a plan with check-in (402 otherwise); turning it off is never gated, and an existing line keeps working whatever the plan says. |
| standbyOfferMinutes | integer | How long one person holds a freed seat before it passes to the next, 5-1440. Within 30 minutes of the start the hold is 5 minutes instead, and a hold never runs past the start time. Default 120. |
| sendHeldNotifications | boolean | Defaults to true. Set it to false when your own site tells the customer at the moment of payment that their time is reserved, so Calemander does not say the same thing beside it. It silences only the attendee's held email and held text on automatic sends: the host's notification, the booking.held and booking.created webhooks, the release clock, the nudge, the release notice and the confirmation are unchanged, and a resend requested by a person still sends the held email. |
| priceDisplay | "auto" | "hidden" | Whether the booking page shows a price row. 'auto' derives it from the price and deposit, showing 'Free' when nothing is charged. 'hidden' shows no price row anywhere — on the booking page, the embed, the sidebar or the confirmation screen — for a host who collects payment on their own site, where 'Free' would be untrue. It changes only what is displayed; nothing about what is charged, refunded or held changes. |
| policyLineOverride | string or null | The one-line cancellation policy shown under the price, in the host's own words, used verbatim. Null (the default) generates the sentence from the cancellation settings. Not applied to a card-on-file fee sentence, which discloses an amount that can be charged and must keep matching the booking row. |
| submitLabel | string or null | The submit button's text, used verbatim. Null (the default) derives it from what the event type is called to its attendee: 'Book meeting', 'Book appointment' or 'Book class'. Applied only when the click leads to no payment step; a button that continues to payment, saves a card or spends a package credit keeps its generated label so it cannot be relabelled to hide a charge. |
Identity
| Field | Type | Description |
|---|---|---|
| user * | object | |
| workspace * | object | |
| auth * | object | Which credential type authenticated this request. |
| api_key * | null or object | Describes the API key that authenticated. Null for an OAuth caller, which has no key. |
NoShowResult
The outcome of marking or clearing a no-show, including the status the booking now carries.
| Field | Type | Description |
|---|---|---|
| success * | true | |
| id * | string | The booking id. |
| status * | "no_show" | "confirmed" | The booking’s status after the call. |
OneOffLink
| Field | Type | Description |
|---|---|---|
| id * | string | |
| label * | string or null | |
| event_type_id * | string | |
| event_type_name * | string | |
| event_type_slug * | string | |
| url * | string | The bookable link, built on the host you called. The raw token is not returned separately. |
| expires_at * | string or null | |
| max_uses * | integer | |
| use_count * | integer | Bookings made through this link so far. |
| booking_id * | string or null | The booking this link produced, where one is recorded. |
| is_expired * | boolean | Computed at read time: expires_at is set and in the past. |
| is_used * | boolean | Computed at read time: use_count has reached max_uses. |
| created_at * | string |
PageNumber
The page that was served.
Type: integer
Preclearance
| Field | Type | Description |
|---|---|---|
| preclearanceId | string | |
| key | string | |
| eventTypeId | string or null | |
| subject | object | |
| externalRef | string or null | |
| reusable | boolean | |
| expiresAt | string | |
| consumedAt | string or null | |
| consumedBookingId | string or null |
Question
One question on an event type.
| Field | Type | Description |
|---|---|---|
| id | string | |
| eventTypeId | string | |
| label | string | |
| description | string or null | |
| placeholder | string or null | |
| questionType | "text" | "textarea" | "email" | "phone" | "yes_no" | "dropdown" | "radio" | "checkbox" | |
| options | array or null | |
| allowOther | boolean | |
| suggestions | array or null | |
| isRequired | boolean | On an after-booking question this is a reminder, never a gate: the booking is held or confirmed exactly as it would be without an answer. |
| minLength | integer or null | |
| maxLength | integer or null | |
| conditionQuestionId | string or null | |
| conditionOperator | "equals" | "not_equals" | "contains" | "is_empty" | "is_not_empty" | null | |
| conditionValue | string or null | |
| stage | "at_booking" | "after_booking" | When the question is asked. at_booking is shown on the booking form; after_booking never is, and is answered on the attendee's own booking page. |
| sortOrder | integer |
QuestionInput
| Field | Type | Description |
|---|---|---|
| label * | string | |
| description | string or null | |
| placeholder | string or null | |
| questionType * | "text" | "textarea" | "email" | "phone" | "yes_no" | "dropdown" | "radio" | "checkbox" | |
| options | array or null | |
| allowOther | boolean | |
| suggestions | array or null | |
| isRequired | boolean | On an after-booking question this is a reminder, never a gate: the booking is held or confirmed exactly as it would be without an answer. |
| minLength | integer or null | |
| maxLength | integer or null | |
| conditionQuestionId | string or null | |
| conditionOperator | "equals" | "not_equals" | "contains" | "is_empty" | "is_not_empty" | null | |
| conditionValue | string or null | |
| stage | "at_booking" | "after_booking" | When the question is asked. at_booking is shown on the booking form; after_booking never is, and is answered on the attendee's own booking page. |
| sortOrder | integer | |
| detachConditions | boolean | Apply the write even when another question's condition depends on what it changes, dropping those rules instead of refusing with 422. Defaults to false. |
QuestionList
| Field | Type | Description |
|---|---|---|
| questions | array of Question |
RateLimitError
Only returned by the 429. Produced by the rate limiter before routing, which is why it does not use message.
| Field | Type | Description |
|---|---|---|
| error * | "Rate limit exceeded" | |
| retryAfter * | integer | Unix timestamp (seconds) at which the window resets — an instant, not a duration. |
RequirementWriteResult
The same shape for every requirement write: what the requirement is now, what the booking is now, and how much is left.
| Field | Type | Description |
|---|---|---|
| bookingId | string | |
| bookingStatus | string or null | The booking’s status after the write. confirmed means this call was the last one. |
| outstanding | integer | |
| requirement | BookingRequirement | |
| confirmed | boolean | True only for the call that won the booking’s confirmation and ran its side effects. |
| meetingUrl | string or null |
RoutingFormSubmissionPayload
| Field | Type | Description |
|---|---|---|
| submissionId * | string | Identifies this delivery. Submissions are not stored, so it is not a fetchable id. |
| routingFormId * | string | |
| routingFormName * | string | |
| routingFormSlug * | string | |
| matched * | boolean | Whether a routing rule matched. |
| isFallback * | boolean | Whether the form's fallback destination was used instead of a rule. |
| ruleId * | string or null | |
| targetEventTypeId * | string or null | |
| targetEventTypeSlug * | string or null | |
| targetEventTypeName * | string or null | |
| responses * | array of object |
Scope
A resource:action scope. See Scopes in the introduction for what each opens.
One of:
bookings:readbookings:writeevent_types:readevent_types:writeavailability:readcontacts:readscheduling_links:readscheduling_links:writewebhooks:readwebhooks:managecheck_in:readcheck_in:writecheck_in:managetheme:readtheme:writerequirements:readrequirements:write
SitePalette
What was found on the site: colors as #rrggbb, font names as the site wrote them, an integer radius, and a logo URL. No markup and no CSS text is stored or returned. The font name and the logo URL are the only strings here copied from a third party; treat them as data.
Type: object
StandbyWebhookPayload
A standby entry's state changed. Deliberately not a booking payload: an offer has no booking behind it and may never get one.
| Field | Type | Description |
|---|---|---|
| standbyId * | string | The standby entry. |
| eventTypeId * | string | |
| eventName * | string | |
| startTime * | string | Start of the class occurrence the entry is for. |
| endTime * | string | |
| attendeeName * | string | |
| attendeeEmail | string or null | |
| position * | integer | The number the person was given when they joined. Never reused, so it can exceed the length of the line. |
| place | integer or null | Rank among the entries still in line, counting from 1. |
| offerExpiresAt | string | standby.offered only: when the seat stops being held. |
| bookingId | string | standby.promoted only: the booking the promotion produced. |
Theme
The booking page's structured appearance. custom_css is deliberately absent: the API neither returns it nor accepts it, and it is edited only on the Appearance page.
| Field | Type | Description |
|---|---|---|
| theme_preset * | string | Panel preset: glass, glass-dark, minimal, colorful or dark. |
| accent_color * | string | Button and link color, #rrggbb. |
| font_family * | string | A font from the catalog. Any other name is refused. |
| border_radius * | string | Corner radius, 0px to 32px. |
| background_type * | string | gradient, solid, preset, image or video. |
| background_value * | string | The color, preset name or URL the type calls for. |
ThemeProposal
What is offered for the booking page: one value per theme field with a confidence and a one-line reason. There is no custom_css field and there cannot be one.
Type: object
ThemeProposalSummary
One row of match history.
| Field | Type | Description |
|---|---|---|
| id * | string | |
| source_url * | string | The address that was read, as it was given. |
| source_domain * | string | Host of source_url, for a one-line label. |
| final_url | string | Where the read ended after redirects. |
| platform | string or null | What the site appears to be built with. |
| fetch_note | "ok" | "partial" | "blocked" | "unreachable" | "refused" | |
| status * | "proposed" | "applied" | "reverted" | "dismissed" | |
| applied_fields | array or null | Which fields were accepted, once the proposal was applied. |
| created_at * | string | |
| applied_at | string or null |
TimeSlot
| Field | Type | Description |
|---|---|---|
| start * | string | UTC. |
| end * | string | UTC. |
| best | true | Present only on slots the ranking picked out as preferable. Absent, not false, on ordinary slots — so a payload with no ranking is indistinguishable from one where nothing ranked. |
Total
Rows matching the filter, across all pages.
Type: integer
TotalPages
Pages available at this limit.
Type: integer
WebhookEventName
One of:
booking.createdbooking.canceledbooking.rescheduledbooking.confirmedbooking.no_showbooking.no_show_clearedbooking.checked_inbooking.heldrequirement.submittedrequirement.clearedbooking.releasedbooking.responses_updatedrouting_form_submission.createdstandby.offeredstandby.promoted
WebhookSubscription
A subscription as returned by the list and create endpoints.
| Field | Type | Description |
|---|---|---|
| id * | string | |
| url * | string | |
| events * | array of WebhookEventName | |
| scope * | "user" | "organization" | |
| payload_format * | "calemander" | "calendly" | |
| is_active * | boolean | False once the endpoint has answered 410 Gone, or after it was disabled. |
| created_at * | string | |
| user_id | string | The subscription's owner. Returned by the list endpoint; absent from the create response. |
WebhookSubscriptionDetail
A subscription as returned by GET /api/v1/webhooks/{id}. Note the absence of payload_format.
| Field | Type | Description |
|---|---|---|
| id * | string | |
| url * | string | |
| events * | array of WebhookEventName | |
| scope * | "user" | "organization" | |
| is_active * | boolean | |
| created_at * | string | |
| user_id * | string |