Errors and rate limits
When a request fails, the API answers with a non-2xx status and a JSON body holding one human-readable message:
{ "error": "API key is missing required scope(s): bookings:read" }Branch on the status code. The messages are written for people and may get clearer over time, so log them and show them, but don't match on their exact text.
Status codes
| Status | Means | Retry? |
|---|---|---|
400 | The request is malformed | No, change the request |
401 | The key is missing, unknown, revoked or expired | No, fix the key |
403 | The key lacks a scope, or the page belongs to another account | No |
404 | No such booking page or booking on this key's account | No |
409 | The slot can't be booked: taken, or not an open slot | Not the same slot. Fetch the slots again and book another. |
429 | Rate limited: the key's per-minute limit, or the guest email's hourly booking limit | Yes, after Retry-After seconds (key limit) or at the top of the next hour (guest limit) |
500 | Failed on clndr.pro's side | Yes, with backoff |
Authentication errors
Every endpoint can return these.
| Status | error | Cause |
|---|---|---|
401 | Missing API key. Send `Authorization: Bearer <key>` or `x-clndr-key` header. | No key arrived. Usually a request to clndr.pro instead of www.clndr.pro: the redirect drops the header. |
401 | Invalid API key | The key doesn't exist: a typo, a truncated paste, or a deleted key. |
401 | API key has been revoked | The key was revoked in the dashboard. |
401 | API key has expired | The key had an expiry date and it has passed. |
403 | API key is missing required scope(s): bookings:read | A publishable key on a secret-only endpoint. The message names the missing scopes. |
429 | Rate limit exceeded. This publishable key allows 120 requests per 60s window. | See Rate limits. |
Endpoint errors
| Endpoint | Status | error |
|---|---|---|
| Get a booking page | 404 | Booking page not found (wrong slug, or the page is inactive) |
| List open slots | 400 | Missing required `date` query param (YYYY-MM-DD) |
400 | `date` must be YYYY-MM-DD or an ISO 8601 timestamp | |
404 | Booking page not found | |
| Create a booking | 400 | Invalid JSON body |
400 | Missing required fields: bookingPageId, guestName, guestEmail, startTime, endTime | |
400 | `guestEmail` must be a valid email address | |
400 | `startTime` and `endTime` must be ISO 8601 timestamps, with `endTime` after `startTime` | |
400 | `responses` must be an array of { questionId, answer } | |
400 | Booking page not found (the page is inactive) | |
403 | This booking page does not belong to the API key owner | |
409 | This time slot is not available. Fetch the slots again and pick another. (publishable key, and the time isn't an open slot right now) | |
409 | This time slot is no longer available. Please pick another. (another booking took it first) | |
429 | Too many booking requests. Please try again later. (this guest email used its 5 booking attempts for the current clock hour; no Retry-After, resets at the top of the hour) | |
500 | Failed to create meeting or An unexpected error occurred (failed on clndr.pro's side; check whether the booking exists before retrying, see Retrying) | |
| Get a booking, cancel | 404 | Booking not found |
| Confirm or cancel a booking | 400 | Invalid JSON body |
400 | `status` must be "confirmed" or "cancelled" | |
404 | Booking not found |
A 500 carries a short message such as Failed to fetch bookings or Failed to update booking.
In the SDK
@clndr-pro/sdk throws a ClndrError for any non-2xx response. It carries the message, the status and the parsed body:
import { Clndr, ClndrError } from '@clndr-pro/sdk';
const clndr = new Clndr(process.env.CLNDR_SECRET_KEY!);
try {
await clndr.bookings.create(input);
} catch (err) {
if (err instanceof ClndrError && err.status === 409) {
// Someone else took the slot: refetch slots and let the guest pick again.
} else {
throw err;
}
}err.status is the HTTP status, err.message is the API's error, and err.data is the whole response body. Network failures (DNS, timeouts) aren't wrapped: they reject with whatever fetch threw. The React hooks expose the same error object as error.
Rate limits
Each key gets a fixed 60-second window:
| Key | Requests per 60 s |
|---|---|
| Secret | 300 |
| Publishable | 120 |
The count is per key, not per visitor: every guest on your site shares your publishable key's 120 requests a minute. A booking flow costs about one request per date the guest looks at, plus two (page and booking), so this covers a busy site. If you outgrow it, move slot lookups to your server and cache them for a minute.
A 429 comes with these headers:
| Header | Value |
|---|---|
Retry-After | Seconds until the window resets |
X-RateLimit-Limit | 300 or 120 |
X-RateLimit-Remaining | 0 |
X-RateLimit-Reset | When the window resets, in Unix seconds |
Successful responses don't include them, so you can't watch your remaining budget; back off when you get a 429.
Two other limits apply to bookings specifically: one guest email address gets five booking attempts per clock hour, counting attempts that then fail (a 429 without Retry-After; it resets at the top of the hour), and each account can create five API keys an hour in the dashboard.
Retrying
GET requests are safe to retry. So are PATCH and DELETE on a booking, with two details: a PATCH without reason clears any cancellation_reason already stored, so resend the same reason; and PATCH doesn't check the current status, so confirming retries are safe but a stale "confirm" after the host declined confirms the booking again.
POST /bookings isn't. If a create times out, the booking may exist. Retrying the same slot then fails with a 409, which might be your own first attempt. From a server, look for the booking (clndr.bookings.list({ status: 'confirmed' }) or 'pending', matching guest_email and start_time) before telling the guest anything. In the browser, show the guest a neutral message and refetch the slots.
A small wrapper that honours Retry-After on idempotent requests:
const BASE = 'https://www.clndr.pro/api/v1';
export async function clndrGet<T>(path: string, key: string, attempts = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${key}` } });
if (res.ok) return (await res.json()) as T;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === attempts) {
const body = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${body.error ?? res.statusText}`);
}
const wait = Number(res.headers.get('Retry-After')) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}