> ## Documentation Index
> Fetch the complete documentation index at: https://cal.id/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Cal ID API Integration

> How the Cal ID REST API is organised — base URL, bearer authentication, JSON envelopes, and the request patterns that trip developers up on their first build.

The Cal ID API is a REST API that gives you programmatic control of the platform. Every resource you can reach in the dashboard is exposed as an HTTP endpoint, so you can create, fetch, update, or delete event types, bookings, availability, connected calendars, schedules, team memberships, contacts, and payments without ever opening the UI. This page covers the conventions that apply to every endpoint, plus the handful of behaviours worth knowing before you write your first integration.

## Base URL

All API requests go to a single host.

```text theme={null}
https://api.cal.id
```

## Server specifications

* Encode requests as JSON. Responses, including errors, are JSON.
* HTTP/1, 1.1, and 2 are supported. HTTP/2 is preferred.
* TLS 1.2 and 1.3 are supported, with session resumption.

The API is organised around REST: predictable resource-oriented URLs, JSON request bodies and responses, and standard HTTP verbs.

<Warning>
  `Content-Type: application/json` is **mandatory** on every `POST` and `PATCH`. Without it the request is rejected with **`415 Unsupported Media Type`** before the body is ever parsed — however valid your JSON is. If you get a `415` on a request that looks correct, this is almost always why.
</Warning>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.cal.id/schedule/ \
    -H "Authorization: Bearer calid_xxxxx" \
    -H "Content-Type: application/json" \
    -d '{"name":"Weekday Working Hours"}'
  ```

  ```javascript JavaScript theme={null}
  async function createSchedule() {
    const res = await fetch("https://api.cal.id/schedule/", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.CALID_KEY}`,
        // Drop this line and the request fails with 415.
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ name: "Weekday Working Hours" }),
    });

    const body = await res.json();
    if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`);
    return body.data;
  }

  createSchedule().then((schedule) => console.log(schedule));
  ```

  ```python Python theme={null}
  import os
  import requests

  CALID_KEY = os.environ["CALID_KEY"]

  response = requests.post(
      "https://api.cal.id/schedule/",
      headers={
          "Authorization": f"Bearer {CALID_KEY}",
          # Drop this line and the request fails with 415.
          "Content-Type": "application/json",
      },
      json={"name": "Weekday Working Hours"},
  )
  response.raise_for_status()

  print(response.json()["data"])
  ```
</CodeGroup>

## Authentication

Every request is authenticated with an API key sent as a bearer token. Keys are prefixed with `calid_`.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.cal.id/users/me \
    -H "Authorization: Bearer calid_xxxxx"
  ```

  ```javascript JavaScript theme={null}
  async function whoAmI() {
    const res = await fetch("https://api.cal.id/users/me", {
      // The Authorization header is the only supported auth method.
      headers: { Authorization: `Bearer ${process.env.CALID_KEY}` },
    });

    const body = await res.json();
    if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`);
    return body.data;
  }

  whoAmI().then((user) => console.log(user.username));
  ```

  ```python Python theme={null}
  import os
  import requests

  CALID_KEY = os.environ["CALID_KEY"]

  # The Authorization header is the only supported auth method.
  response = requests.get(
      "https://api.cal.id/users/me",
      headers={"Authorization": f"Bearer {CALID_KEY}"},
  )
  response.raise_for_status()

  print(response.json()["data"]["username"])
  ```
</CodeGroup>

<Warning>
  The `Authorization` header is the **only** supported authentication method. There is no `?apiKey=` query parameter — a request without the header is rejected regardless of what you put in the URL. Passing keys in query strings would also leak them into logs and browser history.
</Warning>

See [Get your API key](/docs/developers/api-key) to create one.

## Response format

Successful responses wrap the resource in a JSON envelope, and the payload you want lives under `data`. The envelope is built from `success`, `data`, `message`, and `meta` — but **not every endpoint returns all four**, and one class of error does not use the envelope at all.

```json theme={null}
{
  "success": true,
  "data": { },
  "message": "Request completed successfully",
  "meta": { }
}
```

Four shapes were observed across the API:

| Shape                                                 | Seen on                                                              |
| ----------------------------------------------------- | -------------------------------------------------------------------- |
| `success` + `data` + `message` — no `meta`            | `GET /users/me`, `GET /schedule/`, `GET /contacts/`                  |
| `success` + `data` + `meta.pagination` + `message`    | `GET /event-types/`, `GET /booking/`, `GET /teams/`, `GET /webhook/` |
| `success` + `data` — no `message`, no `meta`          | `POST /slots/reserve`                                                |
| `message` + `error` + `statusCode` — **no `success`** | Any unknown route, or a real route called with the wrong HTTP method |

```json Unknown route or wrong method theme={null}
{
  "message": "Route GET:/user/me not found",
  "error": "Not Found",
  "statusCode": 404
}
```

<Warning>
  That last shape is a genuine hazard. Mistype a path or use the wrong verb and the response has **no `success` field**, while `error` is a plain string rather than the `{ code, message }` object the API's own errors return. A client that does `if (!body.success)` or reads `body.error.code` will throw an unrelated exception instead of reporting a clean 404. Branch on the HTTP status code first, then inspect the body.
</Warning>

<Note>
  Always read the resource out of `data` rather than the top level — and do not assume `data` is an object. On list endpoints, `meta` carries pagination and contextual information *where the endpoint provides it at all*.
</Note>

## Things that surprise people

These behaviours account for most first-integration bugs. Read them before you debug.

### List endpoints do not share a default page size — and some ignore paging

There is no single pagination rule. The default `limit`, the parameter names, and even whether paging works at all vary by endpoint.

| Endpoint                                          | Default `limit`    | Paging parameters                         | Counts returned in                        |
| ------------------------------------------------- | ------------------ | ----------------------------------------- | ----------------------------------------- |
| `GET /event-types/`                               | **10**             | `page`, `limit` (max `100`)               | `meta.pagination`                         |
| `GET /teams/` and other `/teams/*` lists          | **10**             | `page`, `limit` (max `100`)               | `meta.pagination`                         |
| `GET /booking/`                                   | **100**            | `page`, `limit`                           | `meta.pagination`                         |
| `GET /webhook/`                                   | **100**            | `page`, `limit`                           | `meta.pagination`                         |
| `GET /contacts/`, `GET /contacts/{id}/meetings`   | **10**             | `limit`, `offset`                         | `data.meta` — nested, different key names |
| `GET /schedule/`, `GET /teams/{teamId}/schedules` | returns everything | **none — `page` and `limit` are ignored** | no `meta` at all                          |

<CodeGroup>
  ```bash cURL theme={null}
  # Returns 10 — the default for /event-types/
  curl "https://api.cal.id/event-types/" \
    -H "Authorization: Bearer calid_xxxxx"

  # Returns up to 100
  curl "https://api.cal.id/event-types/?limit=100" \
    -H "Authorization: Bearer calid_xxxxx"
  ```

  ```javascript JavaScript theme={null}
  async function listEventTypes(limit) {
    const url = new URL("https://api.cal.id/event-types/");
    if (limit !== undefined) url.searchParams.set("limit", String(limit));

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.CALID_KEY}` },
    });

    const body = await res.json();
    if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`);
    return body.data;
  }

  // Returns 10 — the default for /event-types/
  listEventTypes().then((rows) => console.log(rows.length));

  // Returns up to 100
  listEventTypes(100).then((rows) => console.log(rows.length));
  ```

  ```python Python theme={null}
  import os
  import requests

  CALID_KEY = os.environ["CALID_KEY"]
  HEADERS = {"Authorization": f"Bearer {CALID_KEY}"}


  def list_event_types(limit=None):
      params = {} if limit is None else {"limit": limit}
      response = requests.get(
          "https://api.cal.id/event-types/", headers=HEADERS, params=params
      )
      response.raise_for_status()
      return response.json()["data"]


  # Returns 10 — the default for /event-types/
  print(len(list_event_types()))

  # Returns up to 100
  print(len(list_event_types(100)))
  ```
</CodeGroup>

**Contacts nest their pagination.** `GET /contacts/` puts the records in `data.rows` and the counts in `data.meta`, using `totalRowCount`, `hasMore`, `limit`, and `offset` — there is no `total`, `page`, or `totalPages`. Advance `offset` while `data.meta.hasMore` is `true`.

```json theme={null}
{
  "success": true,
  "data": {
    "rows": [ ],
    "meta": { "totalRowCount": 134, "hasMore": true, "limit": 10, "offset": 0 }
  },
  "message": "Request completed successfully"
}
```

**Schedules are not paginated.** `GET /schedule/` and `GET /teams/{teamId}/schedules` accept `page` and `limit` without erroring but ignore them — `?limit=1` still returns the full list — and they return no `meta` object. A paging loop against these endpoints will never terminate.

Asking for more than the ceiling is rejected rather than clamped:

```json theme={null}
{
  "success": false,
  "message": "querystring/limit must be <= 100",
  "error": { "code": "FST_ERR_VALIDATION" }
}
```

<Note>
  The `100` ceiling is confirmed on `/event-types/`, `/contacts/`, and `/teams/*`. It was not tested on `/booking/` or `/webhook/`, which already default to `100`.
</Note>

<Warning>
  Pass an explicit `limit` on every paginated list call, and page through the results rather than assuming one response holds everything. Never infer a total from the number of records you got back.
</Warning>

### `GET /users/{userslug}` returns an array

Even though it looks up a single user by slug, this endpoint returns `data` as an **array**, not an object.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.cal.id/users/jane-doe \
    -H "Authorization: Bearer calid_xxxxx"
  ```

  ```javascript JavaScript theme={null}
  async function getUserBySlug(slug) {
    const res = await fetch(`https://api.cal.id/users/${slug}`, {
      headers: { Authorization: `Bearer ${process.env.CALID_KEY}` },
    });

    const body = await res.json();
    if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`);

    // data is an array here — read the user as data[0].
    return body.data[0];
  }

  getUserBySlug("jane-doe").then((user) => console.log(user.name));
  ```

  ```python Python theme={null}
  import os
  import requests

  CALID_KEY = os.environ["CALID_KEY"]

  response = requests.get(
      "https://api.cal.id/users/jane-doe",
      headers={"Authorization": f"Bearer {CALID_KEY}"},
  )
  response.raise_for_status()

  # data is a list here — read the user as data[0].
  user = response.json()["data"][0]
  print(user["name"])
  ```
</CodeGroup>

Read the user as `data[0]`. Code written as `response.data.name` will return `undefined` rather than erroring, which makes this a quiet bug — you get a null-ish value in your UI instead of a stack trace. `GET /users/me`, by contrast, returns `data` as an object.

### `GET /availability/` needs a date and speaks a different date format

Two things set this endpoint apart from the rest of the API.

**`dateFrom` is required.** Calling `GET /availability/` with no query string returns `400`, not an unbounded list.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G https://api.cal.id/availability/ \
    -H "Authorization: Bearer calid_xxxxx" \
    --data-urlencode "dateFrom=2026-09-08" \
    --data-urlencode "dateTo=2026-09-15"
  ```

  ```javascript JavaScript theme={null}
  async function getAvailability() {
    // dateFrom is required — omitting it returns 400, not an unbounded list.
    const params = new URLSearchParams({
      dateFrom: "2026-09-08",
      dateTo: "2026-09-15",
    });

    const res = await fetch(`https://api.cal.id/availability/?${params}`, {
      headers: { Authorization: `Bearer ${process.env.CALID_KEY}` },
    });

    const body = await res.json();
    if (!res.ok) throw new Error(body.message ?? `HTTP ${res.status}`);

    // Dates here are RFC-1123 ("Tue, 08 Sep 2026 03:30:00 GMT"), not ISO-8601.
    // new Date() parses that format, so normalise at the boundary.
    return body.data;
  }

  getAvailability().then((availability) => console.log(availability));
  ```

  ```python Python theme={null}
  import os
  from email.utils import parsedate_to_datetime

  import requests

  CALID_KEY = os.environ["CALID_KEY"]

  # dateFrom is required — omitting it returns 400, not an unbounded list.
  response = requests.get(
      "https://api.cal.id/availability/",
      headers={"Authorization": f"Bearer {CALID_KEY}"},
      params={"dateFrom": "2026-09-08", "dateTo": "2026-09-15"},
  )
  response.raise_for_status()

  data = response.json()["data"]
  print(data)

  # Dates here are RFC-1123 ("Tue, 08 Sep 2026 03:30:00 GMT"), not ISO-8601.
  # Parse them with parsedate_to_datetime, not datetime.fromisoformat.
  print(parsedate_to_datetime("Tue, 08 Sep 2026 03:30:00 GMT"))
  ```
</CodeGroup>

**It returns RFC-1123 dates, not ISO-8601.** Every other endpoint returns timestamps like `2026-09-08T03:30:00.000Z`. This one returns:

```json theme={null}
"Tue, 08 Sep 2026 03:30:00 GMT"
```

<Warning>
  A shared date parser that assumes ISO-8601 will fail on `GET /availability/`. Parse this endpoint's dates as RFC-1123, or normalise them at the boundary before they reach the rest of your code. Whether any other endpoint shares this format has not been established — treat ISO-8601 as the rule and verify `/availability/` output explicitly.
</Warning>

### Slots and bookings use different time parameters

The parameter names are not interchangeable between the two endpoints.

| Endpoint         | Time parameters   |
| ---------------- | ----------------- |
| `GET /slots/`    | `start` and `end` |
| `POST /booking/` | `start` and `end` |

Both endpoints take `start` and `end`. Sending `startTime`/`endTime` to either is rejected with `400 — body must have required property 'start'`. The trap is that booking *responses* come back with `startTime` and `endTime`, so you cannot echo a booking object straight back as a request body.

### You cannot compute a bookable time yourself

Availability is the product of schedules, buffers, minimum notice, booking limits, connected-calendar busy times, and timezone rules. Reimplementing that logic will not match Cal ID's answer.

<Steps>
  <Step title="Fetch real availability">
    Call `GET /slots/` with `start` and `end` for the window you care about.
  </Step>

  <Step title="Pick a slot from the response">
    Choose one of the times the endpoint actually returned.
  </Step>

  <Step title="Book that exact time">
    Send it to `POST /booking/` as `start`, with a matching `end`.
  </Step>
</Steps>

<Warning>
  Booking a time that `GET /slots/` did not return fails with `no_available_users_found_error`, even when the time looks obviously free on a calendar.
</Warning>

### 400 means your request is malformed; 500 can mean it is unbookable

**Schema problems return `400`.** A missing field, wrong type, out-of-range value or invalid enum comes back as `400` with an `error.code` of `FST_ERR_VALIDATION`, `BAD_REQUEST` or `VALIDATION_ERROR`, and a `message` naming the offending field.

**Scheduling-rule failures return `500`.** A request that is structurally valid but asks for something impossible — most often booking a time that `GET /slots/` did not offer — returns `500` with `error.code: "INTERNAL_ERROR"` and a domain message such as `no_available_users_found_error`.

This matters for retries: a `500` here is **not** a transient fault and retrying with backoff will fail identically. Read `message` before you decide.

The three validation codes are not interchangeable — they carry different `details` payloads. See [Error codes](/docs/developers/error-codes) for each one and how to read it.

### `GET /booking/` returns only upcoming bookings by default

With no `status` parameter you get `status=upcoming` — which on a quiet account
is an empty list, even when the account has hundreds of past bookings. There is
no "all" value. To sweep everything, page each bucket separately:

```
upcoming · past · cancelled · unconfirmed · recurring
```

Any other value is rejected with `400 — querystring/status must be equal to one
of the allowed values`.

## Admin API

Some endpoints accept **Application Admin API** requests for elevated access. Only Application Admins hold an Admin API key; this is not extended to Team Admins.

## Reference

Explore every endpoint, with full schemas and a live "Try it" playground that runs against `https://api.cal.id`, in the [API Reference](/docs/api-reference/introduction).

## API reference and guides

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/docs/developers/quickstart">
    From a fresh API key to a confirmed booking in five requests.
  </Card>

  <Card title="Error codes" icon="triangle-exclamation" href="/docs/developers/error-codes">
    Every `error.code` the API returns, and what to do about each.
  </Card>

  <Card title="Get your API key" icon="key" href="/docs/developers/api-key">
    Create, secure, and rotate the keys that authenticate your requests.
  </Card>

  <Card title="Rate limits" icon="gauge-high" href="/docs/developers/rate-limits">
    Headers, the 429 response, and how to back off cleanly.
  </Card>

  <Card title="Find your Event Type ID" icon="hashtag" href="/docs/developers/event-type-id">
    Locate the numeric IDs your API calls need.
  </Card>

  <Card title="Create a booking" icon="calendar-plus" href="/docs/developers/guides/create-a-booking">
    A step-by-step recipe from slots lookup to confirmed booking.
  </Card>
</CardGroup>


## Related topics

- [Cal ID API Quickstart](/docs/developers/quickstart.md)
- [How to Get Your Cal ID API Key](/docs/developers/api-key.md)
- [Cal ID API Error Codes](/docs/developers/error-codes.md)
- [Cal ID API Rate Limits](/docs/developers/rate-limits.md)
- [How to Find Your Event Type ID](/docs/developers/event-type-id.md)
- [Cal ID Webhooks](/docs/developers/webhooks.md)
- [Cal ID Scheduling API Reference](/docs/api-reference/introduction.md)
