Developers
REST API
Authenticate with API keys and call every operation over HTTP.
The REST API exposes everything an integration needs: route leads, find open times, book and manage meetings, read leads and routing decisions, configure teams, booking links and routers, pull reports and register webhooks. It is the same set of operations the MCP server offers to AI agents, so anything you script here an agent can do too, and the other way round.
Every endpoint lives under /api/v1, takes and returns JSON, and authenticates with a workspace API key. Your installation also has a live reference under Developers → API reference that lists every endpoint with its parameters.
API keys
API keys belong to a workspace and carry permissions (scopes): Standard (everything except deleting and cancelling), Read only, Full access or a custom set. A request that needs a permission the key doesn't have fails with 403 insufficient_scope. Only workspace owners and admins can create or revoke keys. The scopes are listed in MCP server → Permissions; the same API also accepts OAuth access tokens issued to MCP clients.
Create a key
Go to Developers → API keys and click Create key. Name it after where it will be used ("Website backend", "Zapier", "Claude Desktop") and choose when it expires: never, in 30 days, in 90 days or in 1 year.
Copy it once
The full key is shown only once, right after you create it. It looks like cf_live_ followed by 40 random characters. Store it in your secret manager — Cauliflower keeps only a SHA-256 hash and the first 14 characters, which the key list shows so you can tell keys apart.
Revoke when you're done
The key list shows when each key was last used. Click Revoke to disable a key immediately. Expired and revoked keys are rejected with 401.
Changes made with a key show up in Settings → Audit log under the key's name.
Authentication
Send the key as a bearer token:
GET /api/v1/me HTTP/1.1
Host: cal.example.com
Authorization: Bearer cf_live_...X-Api-Key: cf_live_... works too, for tools that can't set an Authorization header. A missing, invalid, expired or revoked key returns:
{ "error": { "code": "unauthorized", "message": "Missing or invalid API key. Use `Authorization: Bearer cf_live_...`" } }Keep keys on the server
The API answers cross-origin requests, but a key in browser code gives anyone its access to your workspace. To route leads from a website, use the widget or the JavaScript API, which call public endpoints that need no key.
Requests and responses
- Base URL:
https://cal.example.com/api/v1— yourAPP_URLplus/api/v1. - Parameters: send a JSON object as the body of
POST,PATCHandPUTrequests.GETandDELETEtake query string parameters;trueandfalsein the query string become booleans. Path parameters always win over body and query values. - Field names are
snake_case. Times are ISO 8601 strings; responses use UTC (2026-10-20T15:00:00.000Z). - IDs carry a prefix that tells you what they are:
mt_booking links,rtr_routers,team_teams,mtg_meetings,lead_leads,rte_routing decisions,whk_webhooks. - Single objects are returned as-is. Lists are wrapped:
{ "data": [ ... ] }. - Status codes:
201forcreate_*operations and booking a meeting,200for everything else. Deletes return{ "deleted": true }.
Pagination
Most lists are small and return everything. The three that can grow take paging parameters:
| Endpoint | Parameters | Response |
|---|---|---|
GET /meetings | page (from 1), page_size (1–200, default 50) | { "data": [...], "page": 1, "page_size": 50, "total": 312 } |
GET /leads | limit (1–200, default 50), offset | { "data": [...] }, newest first |
GET /routing-events | limit (1–200, default 50), from, to | { "data": [...] }, newest first; page backwards with to |
GET /meeting-types/:id/slots returns at most 500 times and searches at most 62 days per request; ask for the next window with from and to.
Errors
Errors use standard HTTP status codes and one JSON shape:
{
"error": {
"code": "validation_error",
"message": "invitee.email: Invalid email address",
"details": [{ "path": "invitee.email", "message": "Invalid email address" }]
}
}| Status | code | Meaning |
|---|---|---|
| 400 | bad_request | The request is understood but can't be done, e.g. "Pass meeting_type_id or routing_token" or "This router is paused". |
| 400 | invalid_json, invalid_body | The body isn't valid JSON, or isn't a JSON object. |
| 401 | unauthorized | Missing, invalid, expired or revoked API key. |
| 404 | not_found | Unknown endpoint, or the resource doesn't exist in this workspace. |
| 409 | conflict, no_hosts | The time was just taken, or nobody can host the meeting. Pick another time. |
| 422 | validation_error | A parameter failed validation. details lists every problem with its path. |
| 429 | rate_limited | Too many requests. |
| 502 | apollo_error | Apollo returned an error during enrichment. |
| 500 | internal_error | Something went wrong on the server. Details are in the server logs. |
Rate limits
Each API key can make 600 requests per minute. Every response includes X-RateLimit-Limit and X-RateLimit-Remaining; when you run out you get 429 until the minute window resets. The counter lives in each server process, so on a multi-replica deployment the effective limit is per replica.
Endpoints
All paths are relative to /api/v1. :router accepts a router id or its slug, and :lead accepts a lead id or email address. The MCP tool with the same behavior is listed for each endpoint.
Workspace and members
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /me | The workspace this key belongs to: id, name, slug, time zone and public booking page URL. | get_workspace |
| GET | /members | People in the workspace with user id, name, email, role, title, handle and active status. | list_members |
| GET | /members/:user_id/availability | A member's weekly hours, date overrides and time zone. | get_availability |
| PUT | /members/:user_id/availability | Replace a member's weekly hours and/or overrides, and optionally their timezone. | update_availability |
Teams
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /teams | Round robin teams with members, weights and assignment counts. | list_teams |
| GET | /teams/:team_id | One team with members and distribution stats. | get_team |
| POST | /teams | Create a team: name, distribution (round_robin, load_balanced, priority), rr_mode (flexible, strict), member_user_ids. | create_team |
| PATCH | /teams/:team_id | Update name, description, distribution, rr_mode or load_window_days. | update_team |
| PUT | /teams/:team_id/members | Replace the roster. Each member has user_id, weight (0–100), priority and is_active. | set_team_members |
| DELETE | /teams/:team_id | Delete a team. Booking links and routers that use it stop offering times. | delete_team |
Booking links
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /meeting-types | Booking links with their public URLs. | list_booking_links |
| GET | /meeting-types/:meeting_type_id | One booking link. | get_booking_link |
| POST | /meeting-types | Create a booking link. Pass owner_user_id for individual hosting, team_id for round_robin or collective. | create_booking_link |
| PATCH | /meeting-types/:meeting_type_id | Update any field. Omitted fields keep their values. | update_booking_link |
| DELETE | /meeting-types/:meeting_type_id | Delete a booking link. Existing meetings are kept. | delete_booking_link |
| GET | /meeting-types/:meeting_type_id/slots | Bookable start times between from and to, optionally for one host_user_id. Pass a routing_token from route_lead to get the times that routed lead can book. Each slot lists the hosts free at that time. | find_available_slots |
Routers
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /routers | All routers. | list_routers |
| GET | /routers/:router | A router's form fields, flow (trigger, matching, paths, catch-all) and settings. | get_router |
| POST | /routers | Create a router from a flow. See Routers for the flow shape. | create_router |
| PATCH | /routers/:router | Update a router. Pass the complete flow to replace its paths; get the current one first. | update_router |
| DELETE | /routers/:router | Delete a router. Routing history is kept. | delete_router |
| POST | /routers/:router/route | Run a lead through the router. Supports dry_run. | route_lead |
Meetings
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /meetings | List meetings. Filter by status (upcoming by default, past, cancelled, all), host_user_id, lead_id, source, q, from, to. | list_meetings |
| GET | /meetings/:meeting_id | One meeting with host, invitee, answers, routing info and manage URL. | get_meeting |
| POST | /meetings | Book a meeting with a meeting_type_id or a routing_token. Invites are sent to the invitee. | book_meeting |
| POST | /meetings/:meeting_id/cancel | Cancel with an optional reason. Removes the calendar event and notifies everyone. | cancel_meeting |
| POST | /meetings/:meeting_id/reschedule | Move to a new start_time with the same hosts. | reschedule_meeting |
| POST | /meetings/:meeting_id/status | Mark a past meeting completed or no_show, or reset it to confirmed. | update_meeting_status |
Leads, routing and reports
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /leads | Leads, newest first. Filter by q, status (new, routed, booked, assigned, disqualified) or owner_user_id. | list_leads |
| GET | /leads/:lead | A lead with its last 20 routing decisions and meetings. | get_lead |
| POST | /leads/:lead/enrich | Look the lead up in Attio and Apollo and store the result. force: true skips the cache. | enrich_lead |
| GET | /routing-events | Routing decisions, newest first. Filter by router, outcome, from, to. | list_routing_events |
| GET | /reports/:report | Analytics for from–to (default: the last 30 days). :report is overview, reps, routers, disqualifications, teams or handoffs. | get_report |
Webhooks
| Method | Path | Description | MCP tool |
|---|---|---|---|
| GET | /webhooks | Webhook endpoints with their events, status and consecutive failures. Secrets are not returned. | list_webhooks |
| POST | /webhooks | Register an endpoint with url, events and an optional description. The signing secret is returned once. | create_webhook |
| DELETE | /webhooks/:webhook_id | Delete an endpoint. | delete_webhook |
See Webhooks for event types, payloads and signatures.
Route a lead
POST /routers/:router/route runs a lead through a router exactly like a website form submission would: lead matching, enrichment, paths, then the steps of the winning path. The lead must include an email; any other keys become fields you can route on.
curl -X POST https://cal.example.com/api/v1/routers/inbound/route \
-H "Authorization: Bearer $CAULIFLOWER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"lead": {
"email": "[email protected]",
"name": "Jane Cooper",
"company": "Acme",
"company_size": "201-1000"
},
"page_url": "https://acme.com/demo?utm_source=google"
}'{
"routing_event_id": "rte_4w9t2m8k1c0d7h3x5q",
"outcome": "book",
"matched_path": { "id": "path_8f2k1m0q9c3x7w4t5z", "name": "Mid-market" },
"matched_rule": { "id": "path_8f2k1m0q9c3x7w4t5z", "name": "Mid-market" },
"catch_all": false,
"message": null,
"disqualify_reason": null,
"redirect_url": null,
"redirect_delay_seconds": null,
"enrichment": { "found": true, "source": "apollo", "title": "Head of Sales", "employees": 520, "industry": "computer software", "enrichedAt": "2026-10-07T14:02:11.000Z" },
"meeting_type": { "id": "mt_2c7d0x9k4m1q8w3t6z", "title": "Product demo", "slug": "demo", "durationMinutes": 30 },
"candidate_hosts": [
{ "user_id": "Hq3vN8rT2kLm", "name": "Alex Kim" },
{ "user_id": "Pz7wC1yD5sJe", "name": "Sam Rivera" }
],
"distribution": { "mode": "round_robin", "reason": "Round robin · Sales" },
"assigned_user": null,
"lead_id": "lead_7m3q9x2k8c1w0t4d5z",
"routing_token": "Vb8Kx2...",
"booking_url": "https://cal.example.com/r/Vb8Kx2...",
"error": null
}outcome is one of:
| Outcome | What happened | What to do |
|---|---|---|
book | The path shows a calendar. | Send the lead to booking_url, or book for them with routing_token. |
assigned | The path assigned an owner without a meeting. | assigned_user has the owner. |
disqualified | The path disqualified the lead. | Show message; disqualify_reason says why. |
redirect | The path only redirects. | Send the lead to redirect_url. |
no_match | No path matched and the catch-all is empty or only notifies and updates the CRM. | Handle it yourself. |
error | A step failed, e.g. no available hosts. | error explains what went wrong. |
The router's trigger must include API & AI agents; otherwise the request fails with 400. Paused routers also return 400. A real (non-dry) run saves the lead and the routing decision, fires lead.* webhooks, and queues the path's notify and CRM steps (on calendar paths, the parts that need the booked rep run once the meeting is booked; see Path steps).
Test with dry_run
Add "dry_run": true to evaluate the router without side effects: no lead or routing decision is saved, no webhooks fire, nobody is notified and round robin counters don't move. The response has the same shape plus a trace array that shows, for every path, whether it matched and the actual value of each condition:
{
"outcome": "book",
"routing_token": null,
"booking_url": null,
"trace": [
{
"ruleId": "path_1",
"ruleName": "Enterprise",
"matched": false,
"conditions": [{ "field": "enrichment.employees", "operator": "gte", "expected": 1000, "actual": 520, "passed": false }]
},
{ "ruleId": "path_2", "ruleName": "Mid-market", "matched": true, "conditions": [] }
]
}Dry runs never return a routing_token, so they can't be booked. Lead matching still runs, so a dry run can call Apollo for a lead it hasn't enriched before.
Book a meeting
POST /meetings books a meeting in one of two ways.
With a routing token
After route_lead returns book, you can book on the lead's behalf with the routing_token instead of sending them to booking_url. The token pins the meeting type and the candidate hosts the router chose.
Find a time
Ask for open times on the returned meeting type, passing the same routing_token. With a token, the slots endpoint returns the times this lead can book: only the hosts the router chose, under the booking link's public rules. The token decides the meeting type and hosts, so host_user_id isn't needed.
curl "https://cal.example.com/api/v1/meeting-types/mt_2c7d0x9k4m1q8w3t6z/slots?routing_token=Vb8Kx2...&from=2026-10-12T00:00:00Z&to=2026-10-16T00:00:00Z" \
-H "Authorization: Bearer $CAULIFLOWER_API_KEY"{
"timezone": "UTC",
"count": 38,
"slots": [
{ "start": "2026-10-12T14:00:00.000Z", "end": "2026-10-12T14:30:00.000Z", "hosts": ["Hq3vN8rT2kLm", "Pz7wC1yD5sJe"] }
]
}Book it
curl -X POST https://cal.example.com/api/v1/meetings \
-H "Authorization: Bearer $CAULIFLOWER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"routing_token": "Vb8Kx2...",
"start_time": "2026-10-12T14:00:00Z",
"timezone": "America/New_York",
"invitee": { "name": "Jane Cooper", "email": "[email protected]" },
"notes": "Wants to see Attio sync"
}'The response is the full meeting, including host, meeting_url, manage_url and routing_event_id, with status 201.
A routing token can book one meeting and is valid for 7 days. The meeting is recorded as a router booking, so it counts toward the router's conversion in reports, and the meeting type's minimum notice, booking window, required questions and per-invitee limits apply just as on the hosted page. Called with the token, the slots endpoint applies the same minimum notice and booking window and ignores meeting_type_id and host_user_id, so the times it returns match what the lead could pick. Without a token, it uses the more relaxed internal rules described below. Round robin picks among the candidates who are free at the chosen time.
With a meeting type
Pass meeting_type_id (and optionally host_user_id) to book a booking link directly, for example from your own scheduling UI:
curl -X POST https://cal.example.com/api/v1/meetings \
-H "Authorization: Bearer $CAULIFLOWER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"meeting_type_id": "mt_2c7d0x9k4m1q8w3t6z",
"start_time": "2026-10-20T15:00:00Z",
"timezone": "America/New_York",
"invitee": { "name": "Sam Lee", "email": "[email protected]", "phone": "+1 555 0100" },
"guests": ["[email protected]"],
"lead": { "company": "TinyCo", "company_size": "11-50" },
"answers": { "team_size": "12" }
}'These bookings are treated as internal, like Handoff: they need only 5 minutes of notice, can be up to at least 120 days ahead, and required questions are optional. The meeting's source is api.
Booking rules
start_timeshould be a time returned by the slots endpoint. Availability is re-checked against fresh calendar data when you book; if the time was taken in the meantime you get409and should pick another.- Sending the same request twice (same booking link, invitee email and start time within 15 minutes) returns the meeting that was already created instead of booking a second one, so retries are safe.
invitee.nameandinvitee.emailare required.gueststakes up to 10 email addresses.leadstores extra attributes on the lead record.