# Errors and rate limits

Every error the clndr.pro API returns, with its exact message, what causes it and whether to retry, plus the per-key rate limits and how to back off.

When a request fails, the API answers with a non-2xx status and a JSON body holding one human-readable message:

```json
{ "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](#rate-limits).                                                                            |

## Endpoint errors

| Endpoint                                                                                                 | Status | `error`                                                                                                                                                                         |
| -------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Get a booking page](/api-reference/operations/getBookingPage)                                           | `404`  | `Booking page not found` (wrong slug, or the page is inactive)                                                                                                                  |
| [List open slots](/api-reference/operations/listSlots)                                                   | `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](/api-reference/operations/createBooking)                                              | `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](#retrying))          |
| [Get a booking](/api-reference/operations/getBooking), [cancel](/api-reference/operations/cancelBooking) | `404`  | `Booking not found`                                                                                                                                                             |
| [Confirm or cancel a booking](/api-reference/operations/updateBooking)                                   | `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:

```ts title="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:

| 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:

```ts title="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));
  }
}
```
