Developer documentation
Every SchedulerCX account is an API surface: a versioned REST API described by an OpenAPI 3.1 contract, HMAC-signed webhooks, and a built-in MCP server so AI agents can check availability, book, reschedule and cancel on your behalf — all against the same rules as your public booking page.
Overview
Everything is scoped to one tenant. A credential (API key or OAuth client) belongs to exactly one account, and every resource it can see or touch belongs to that account — there is no cross-tenant access, so you never pass a user id.
Three integration surfaces share one service layer, so they can never disagree about what's bookable:
REST API
Versioned under /api/v1, described by OpenAPI 3.1. Idempotency keys, cursor pagination, RFC 7807 errors.
Webhooks
HMAC-signed POSTs for booking.created, booking.requested, booking.approved, booking.cancelled and booking.rescheduled.
MCP server
Six agent tools over streamable HTTP at /api/mcp, authenticated with the same credentials.
Base URLs
| Surface | URL |
|---|---|
| REST API base | http://portal.dialoguecx.ai/api/v1 |
| OpenAPI 3.1 spec | http://portal.dialoguecx.ai/openapi.yaml |
| MCP server (streamable HTTP) | http://portal.dialoguecx.ai/api/mcp |
| OAuth 2.0 token endpoint | http://portal.dialoguecx.ai/api/v1/oauth/token |
| Embed script | http://portal.dialoguecx.ai/embed.js |
Quickstart
Create an API key in Dashboard → API & MCP (it's shown once — store it as $KEY), then:
# 1. Grab your profile — sanity-checks the key
curl -H "Authorization: Bearer $KEY" http://portal.dialoguecx.ai/api/v1/me
# 2. Find open slots for an event type
curl -H "Authorization: Bearer $KEY" \
"http://portal.dialoguecx.ai/api/v1/availability?event_type=intro-call&start=2026-07-10&end=2026-07-14"
# 3. Book one of them (idempotently)
curl -X POST http://portal.dialoguecx.ai/api/v1/bookings \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"event_type": "intro-call",
"start_time": "2026-07-10T10:00:00-04:00",
"invitee_name": "Ada Lovelace",
"invitee_email": "ada@example.com"
}'Authentication
Both the REST API and the MCP server accept the same two bearer credentials (RFC 6750): long-lived API keys, or short-lived OAuth 2.0 access tokens. Send either as Authorization: Bearer <token>.
API keys
Keys look like sk_live_… (or sk_test_…), are scoped at creation, revocable, and stored server-side only as a peppered HMAC-SHA256 hash — the full key is shown exactly once. Best for server-to-server integrations and MCP clients you control.
OAuth 2.0 client credentials
For platforms that prefer short-lived tokens, exchange a client id/secret for a one-hour access token (RFC 6749 §4.4). The optional scope parameter requests a subset of the client's granted scopes — tokens never exceed what the credential was granted, even if grants are later reduced.
curl -X POST http://portal.dialoguecx.ai/api/v1/oauth/token \
-d grant_type=client_credentials \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d "scope=availability:read bookings:read bookings:write"
# → { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600,
# "scope": "availability:read bookings:read bookings:write" }Scopes
Every operation requires a scope; a missing scope yields 403 with type …/errors/forbidden listing required_scope and granted_scopes.
| Scope | Grants |
|---|---|
profile:read | Read the tenant profile (GET /me) |
profile:write | Update profile settings (PATCH /me) |
event-types:read | List and read event types |
event-types:write | Create, update, delete event types |
availability:read | Compute bookable slots |
bookings:read | List, read and search bookings |
bookings:write | Create, reschedule, cancel bookings |
webhooks:manage | Create, list, delete webhook endpoints |
Conventions
Timestamps & timezones
All instants are RFC 3339 / ISO 8601 with an explicit UTC offset, e.g. 2026-07-10T14:00:00-04:00, rendered in the resource's IANA timezone. Storage is UTC; DST math is done per calendar date in the tenant's zone, so a "9:00–17:00" rule stays 9:00–17:00 wall-clock across transitions. Where a bare date is accepted (availability start/end), it is interpreted in the request's timezone, inclusive on the end date.
Errors (RFC 7807)
Every error is an application/problem+json document with a stable type URI you can branch on — never match on human-readable text. Conflict responses carry extensions that make retries possible without a human:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "http://portal.dialoguecx.ai/errors/slot-conflict",
"title": "Requested time is not available",
"status": 409,
"detail": "That slot was booked a moment ago.",
"instance": "/api/v1/bookings",
"conflicting_bookings": [
{ "id": "bk_123", "start_time": "2026-07-10T10:00:00-04:00",
"end_time": "2026-07-10T10:30:00-04:00" }
],
"alternative_slots": [
{ "start": "2026-07-10T10:30:00-04:00", "end": "2026-07-10T11:00:00-04:00" },
{ "start": "2026-07-10T11:00:00-04:00", "end": "2026-07-10T11:30:00-04:00" }
]
}| type slug | Status | Meaning |
|---|---|---|
validation | 400 | Malformed or missing request fields |
unauthorized | 401 | Missing, invalid, or revoked credential |
forbidden | 403 | Credential lacks the required scope |
not-found | 404 | No such resource in this tenant |
slot-conflict | 409 | Slot taken / outside availability — includes alternative_slots |
ambiguous-match | 409 | Lookup matched several bookings — includes matches to retry with |
idempotency-conflict | 409 | Idempotency-Key reused with a different body |
already-cancelled | 409 | Booking is already cancelled |
has-upcoming-bookings | 409 | Event type still has confirmed future bookings |
slug-taken | 409 | Event type slug already in use |
missing-answers | 422 | Required custom questions unanswered |
rate-limited | 429 | Per-credential limit hit — honor Retry-After |
internal | 500 | Unexpected server error |
Pagination
List endpoints are cursor-paginated: pass limit (1–100, default 20) and the cursor from the previous page's next_cursor. A null next_cursor means you've reached the end. Cursors are opaque — don't construct them.
curl -H "Authorization: Bearer $KEY" \
"http://portal.dialoguecx.ai/api/v1/bookings?limit=50&status=confirmed"
# → { "data": [ … ], "next_cursor": "bk_0a1b2c" }
curl -H "Authorization: Bearer $KEY" \
"http://portal.dialoguecx.ai/api/v1/bookings?limit=50&status=confirmed&cursor=bk_0a1b2c"
# → { "data": [ … ], "next_cursor": null } // null ⇒ last pageIdempotency
Every mutating endpoint accepts an Idempotency-Key header (any unique string ≤ 255 chars; UUIDv4 recommended). Retrying with the same key and body replays the original response (marked Idempotency-Replayed: true); the same key with a different body returns 409 idempotency-conflict. Always send one when creating bookings from a queue or an agent — a timeout plus a retry must not double-book your invitee.
Rate limits
Limits are enforced per credential. On 429 you get a Retry-After header (seconds) and a rate-limited problem document — back off and retry after that interval.
REST API
The full request/response contract lives in the interactive API reference (Swagger UI) — you can authorize with an API key there and call this deployment directly. The table below is the map:
Endpoint reference
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /me | profile:read | Tenant profile, booking page & MCP URLs |
| PATCH | /me | profile:write | Update name, handle, timezone, notification email |
| GET | /event-types | event-types:read | List event types (filter: active) |
| POST | /event-types | event-types:write | Create an event type |
| GET | /event-types/{id} | event-types:read | Get one event type |
| PATCH | /event-types/{id} | event-types:write | Partial update |
| DELETE | /event-types/{id} | event-types:write | Delete (409 if upcoming bookings exist) |
| GET | /availability | availability:read | Bookable slots for an event type (≤ 60 days) |
| GET | /bookings | bookings:read | List/search bookings (status, invitee, range) |
| POST | /bookings | bookings:write | Create a booking at an available slot |
| GET | /bookings/{id} | bookings:read | Get one booking |
| DELETE | /bookings/{id} | bookings:write | Cancel (soft; optional ?reason=) |
| POST | /bookings/{id}/approve | bookings:write | Confirm a pending booking request |
| POST | /bookings/{id}/reschedule | bookings:write | Move to a new start_time |
| GET | /webhooks | webhooks:manage | List webhook endpoints |
| POST | /webhooks | webhooks:manage | Create endpoint (secret shown once) |
| DELETE | /webhooks/{id} | webhooks:manage | Delete endpoint |
| POST | /oauth/token | none | Exchange client credentials for a token |
All paths are relative to http://portal.dialoguecx.ai/api/v1.
Reading availability
GET /availability returns real bookable slots: weekly rules and date overrides, minus existing bookings, buffers, and the event type's minimum notice. Never compute slots yourself — a slot the endpoint returns is one POST /bookings will accept (barring a race, which is handled below).
curl -H "Authorization: Bearer $KEY" \
"http://portal.dialoguecx.ai/api/v1/availability?event_type=intro-call&start=2026-07-10&end=2026-07-12&timezone=America/Toronto"
{
"event_type_id": "et_8f7d…",
"timezone": "America/Toronto",
"slots": [
{ "start": "2026-07-10T09:00:00-04:00", "end": "2026-07-10T09:30:00-04:00" },
{ "start": "2026-07-10T09:30:00-04:00", "end": "2026-07-10T10:00:00-04:00" },
…
]
}Booking lifecycle
Bookings move through three states: confirmed → cancelled (soft, keeps the record) or rescheduled (the original points to its replacement via rescheduled_from_id). Double-booking is impossible even under concurrent writers — a database exclusion constraint guarantees it — so a lost race surfaces as a clean 409 slot-conflict with alternative_slots you can offer instead.
# Reschedule: old booking → status "rescheduled", replacement returned
curl -X POST http://portal.dialoguecx.ai/api/v1/bookings/bk_123/reschedule \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "start_time": "2026-07-11T14:00:00-04:00" }'
# Cancel: soft state change, record stays readable
curl -X DELETE "http://portal.dialoguecx.ai/api/v1/bookings/bk_123?reason=Client%20asked" \
-H "Authorization: Bearer $KEY"Webhooks
Register an endpoint with POST /webhooks choosing any of booking.created, booking.requested, booking.approved, booking.cancelled, booking.rescheduled. The signing secret is returned only on creation. Deliveries time out after 5 s; respond 2xx quickly and process async.
POST <your endpoint>
Content-Type: application/json
X-Timestamp: 1783958400
X-Signature: sha256=7f3b…c9d1
User-Agent: SchedulerCX-Webhooks/1.0
{
"id": "evt_lyx8k2ab3f",
"event": "booking.created",
"created_at": "2026-07-10T14:00:02.000Z",
"data": { …the booking, same shape as GET /bookings/{id}… }
}Verify like Stripe/GitHub: recompute the HMAC over `${X-Timestamp}.${rawBody}` and compare in constant time, rejecting stale timestamps:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/schedulercx",
express.raw({ type: "application/json" }), // keep the RAW body — sign bytes, not JSON
(req, res) => {
const ts = req.header("X-Timestamp") ?? "";
const sig = req.header("X-Signature") ?? "";
// 1. Reject stale timestamps (replay protection)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
// 2. Recompute HMAC over "<timestamp>.<raw body>" and compare constant-time
const expected =
"sha256=" +
createHmac("sha256", process.env.SCHEDULERCX_WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest("hex");
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body.toString("utf8"));
// event: "booking.created" | "booking.requested" | "booking.approved"
// | "booking.cancelled" | "booking.rescheduled"
res.sendStatus(200);
},
);MCP server
The Model Context Protocol server at http://portal.dialoguecx.ai/api/mcp (streamable HTTP transport) exposes scheduling as agent tools. It authenticates with the same bearer credentials as the REST API and is implemented on the same service layer — an agent booking a slot obeys exactly the same availability, buffer, notice and conflict rules, and bookings it creates are marked created_via: "agent".
Tools
| Tool | Scope | What it does |
|---|---|---|
list_event_types | event-types:read | Bookable meeting kinds, incl. required custom questions |
get_availability | availability:read | Open slots for an event type in a date range (≤ 60 days) |
create_booking | bookings:write | Book a slot; conflicts return alternative_slots to offer |
find_bookings | bookings:read | Search by status, event type, invitee email/phone, time range |
cancel_booking | bookings:write | Cancel by booking_id or invitee details (+ date to disambiguate) |
reschedule_booking | bookings:write | Move a booking to a new available slot |
Tool errors return the same RFC 7807 documents as REST, as structured tool results — so an agent hitting a slot conflict sees alternative_slots and can offer the invitee a new time, and an ambiguous cancel request gets a matches list to ask the human about. Give agent credentials only the scopes they need — a read-only assistant works fine with just event-types:read + availability:read + bookings:read.
Connect from Claude Code
claude mcp add --transport http schedulercx http://portal.dialoguecx.ai/api/mcp \ --header "Authorization: Bearer sk_live_your_key_here" # then, inside Claude Code: # > find a 30-minute slot next Tuesday afternoon and book it # > for jane@acme.com — she wants to discuss the Q3 rollout
Connect from Claude Desktop / claude.ai
Add a custom connector (Settings → Connectors → Add custom connector) pointing at http://portal.dialoguecx.ai/api/mcp, or use an mcp.json-style config where your client supports HTTP servers with headers:
{
"mcpServers": {
"schedulercx": {
"type": "http",
"url": "http://portal.dialoguecx.ai/api/mcp",
"headers": {
"Authorization": "Bearer sk_live_your_key_here"
}
}
}
}Other clients (stdio-only)
Clients that only speak stdio can bridge through mcp-remote:
{
"mcpServers": {
"schedulercx": {
"command": "npx",
"args": [
"-y", "mcp-remote", "http://portal.dialoguecx.ai/api/mcp",
"--header", "Authorization: Bearer sk_live_your_key_here"
]
}
}
}Your personal MCP URL also appears in GET /me as mcp_url, and in Dashboard → API & MCP.
Embed widget
Drop the booking page into any site with one script tag — inline, or as a popup triggered by any element. Replace your-slug with your booking page slug:
<!-- Inline embed: renders the booking page in place --> <div data-schedulercx-inline="http://portal.dialoguecx.ai/your-slug" style="min-height:700px"></div> <script src="http://portal.dialoguecx.ai/embed.js" async></script> <!-- Popup: opens the booking page in a modal --> <button data-schedulercx-popup="http://portal.dialoguecx.ai/your-slug">Book a time</button> <script src="http://portal.dialoguecx.ai/embed.js" async></script>