Skip to main content
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.

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

Authentication

Every request is authenticated with an API key sent as a bearer token. Keys are prefixed with calid_.
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.
See Get your 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.
Four shapes were observed across the API:
Unknown route or wrong method
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.
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.

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.
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.
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:
The 100 ceiling is confirmed on /event-types/, /contacts/, and /teams/*. It was not tested on /booking/ or /webhook/, which already default to 100.
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.

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.
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.
It returns RFC-1123 dates, not ISO-8601. Every other endpoint returns timestamps like 2026-09-08T03:30:00.000Z. This one returns:
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.

Slots and bookings use different time parameters

The parameter names are not interchangeable between the two endpoints. 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.
1

Fetch real availability

Call GET /slots/ with start and end for the window you care about.
2

Pick a slot from the response

Choose one of the times the endpoint actually returned.
3

Book that exact time

Send it to POST /booking/ as start, with a matching end.
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.

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

API reference and guides

Quickstart

From a fresh API key to a confirmed booking in five requests.

Error codes

Every error.code the API returns, and what to do about each.

Get your API key

Create, secure, and rotate the keys that authenticate your requests.

Rate limits

Headers, the 429 response, and how to back off cleanly.

Find your Event Type ID

Locate the numeric IDs your API calls need.

Create a booking

A step-by-step recipe from slots lookup to confirmed booking.