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

StatusMeansRetry?
400The request is malformedNo, change the request
401The key is missing, unknown, revoked or expiredNo, fix the key
403The key lacks a scope, or the page belongs to another accountNo
404No such booking page or booking on this key's accountNo
409The slot can't be booked: taken, or not an open slotNot the same slot. Fetch the slots again and book another.
429Rate limited: the key's per-minute limit, or the guest email's hourly booking limitYes, after Retry-After seconds (key limit) or at the top of the next hour (guest limit)
500Failed on clndr.pro's sideYes, with backoff

Authentication errors

Every endpoint can return these.

StatuserrorCause
401Missing 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.
401Invalid API keyThe key doesn't exist: a typo, a truncated paste, or a deleted key.
401API key has been revokedThe key was revoked in the dashboard.
401API key has expiredThe key had an expiry date and it has passed.
403API key is missing required scope(s): bookings:readA publishable key on a secret-only endpoint. The message names the missing scopes.
429Rate limit exceeded. This publishable key allows 120 requests per 60s window.See Rate limits.

Endpoint errors

EndpointStatuserror
Get a booking page404Booking page not found (wrong slug, or the page is inactive)
List open slots400Missing required `date` query param (YYYY-MM-DD)
400`date` must be YYYY-MM-DD or an ISO 8601 timestamp
404Booking page not found
Create a booking400Invalid JSON body
400Missing 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 }
400Booking page not found (the page is inactive)
403This booking page does not belong to the API key owner
409This time slot is not available. Fetch the slots again and pick another. (publishable key, and the time isn't an open slot right now)
409This time slot is no longer available. Please pick another. (another booking took it first)
429Too 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)
500Failed 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, cancel404Booking not found
Confirm or cancel a booking400Invalid JSON body
400`status` must be "confirmed" or "cancelled"
404Booking 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:

lib/book.ts
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:

KeyRequests per 60 s
Secret300
Publishable120

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:

HeaderValue
Retry-AfterSeconds until the window resets
X-RateLimit-Limit300 or 120
X-RateLimit-Remaining0
X-RateLimit-ResetWhen 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:

lib/clndr-fetch.ts
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));
  }
}