the Core API Scheduling API Contracts
The scheduling system (formerly the standalone Intakes microservice) is merged into the the Core API Go API. All routes use the /v1/ prefix. Terminology changes:
| Old term | New term | Reason |
|---|---|---|
| Opening | Specialist | Represents a specialist with scheduling properties (timezone, weekly hours, overrides) |
| Schedule | Appointment Type | Defines booking rules + clinical configuration for a type of appointment |
| Intake | Appointment | Represents a booked appointment; now has status booked, nullable patient_id, contact fields |
Authentication
Clerk-Authenticated Routes (Admin / Specialist)
All org-scoped routes require the Core API's Clerk auth middleware. The authenticated user's organization is resolved from the Clerk session — no API keys, no Bearer sk_* tokens.
| Header | Value |
|---|---|
Authorization | Clerk session token (managed by Clerk SDK / session cookie) |
the Core API's existing RBAC determines what the user can access within their org.
Public Routes (Booking Flow)
Public routes require no authentication. They are used by the patient-facing booking UI. All public routes are IP rate-limited (see docs/04-auth-and-security.md — IP Rate Limiting).
| Route prefix | Auth | IP Rate Limit |
|---|---|---|
GET /v1/appointment-types/{id}/details | None | 30/min |
GET /v1/appointment-types/{id}/timeslots | None | 30/min |
POST /v1/appointment-types/{id}/book | None | 10/hour |
POST /v1/holds | None | 20/min |
PATCH /v1/holds | None | 60/min |
GET /v1/holds | None | 30/min |
POST /v1/holds/check | None | 30/min |
POST /v1/holds/release-all | None | 10/min |
GET /v1/holds/stream | None | 10/min |
Appointment Types (Org-Scoped, Clerk Auth)
GET /v1/appointment-types
List appointment types for the authenticated organization. Supports advanced query DSL for filtering, sorting, and pagination.
Query Parameters: Advanced query system (filters, sort, pagination).
Response:
{
"data": [AppointmentType],
"meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}POST /v1/appointment-types
Create an appointment type.
Body:
{
"displayName": "string (required)",
"slotDurationMinutes": "int >= 1 (required)",
"slotGapMinutes": "int >= 0 (default 0)",
"slotsCooldownMinutes": "int >= 0 (default 1440)",
"slotsHorizonDays": "int >= 0 (default 0)",
"requiresTimeslot": "boolean (default true)",
"requiresPayment": "boolean (default false)",
"slotsOpenAt": "ISO 8601 (optional)",
"slotsCloseAt": "ISO 8601 (optional)",
"metadata": "object (optional)"
}Response: 201 — Created appointment type.
GET /v1/appointment-types/{id}
Get a single appointment type by ID.
Response:
{ "appointmentType": AppointmentType }PATCH /v1/appointment-types/{id}
Update an appointment type. Updates invalidate the timeslot cache.
Body: Any subset of the fields from POST /v1/appointment-types.
DELETE /v1/appointment-types/{id}
Delete an appointment type. Invalidates the timeslot cache.
Specialist Assignment
GET /v1/appointment-types/{id}/specialists
List specialists assigned to this appointment type, with priority info.
Response:
{
"data": [
{
"specialistId": "uuid",
"displayName": "string",
"priority": 1,
"active": true
}
]
}POST /v1/appointment-types/{id}/specialists
Assign a specialist to this appointment type.
Body:
{ "specialistId": "uuid (required)", "priority": "int (required)" }DELETE /v1/appointment-types/{id}/specialists/{specialistId}
Remove a specialist from this appointment type. Invalidates cache.
PATCH /v1/appointment-types/{id}/specialists/reorder
Bulk reorder specialist priorities on this appointment type.
Body:
{
"specialistIds": ["uuid", "uuid"],
"evenDistribution": "boolean (optional, default false)"
}evenDistribution=true sets all priorities to 0 (pure round-robin). false uses descending priority based on array order.
Override Cleanup
DELETE /v1/appointment-types/{id}/overrides/cleanup
Delete expired overrides for all specialists on this appointment type. ?strategy=aggressive|moderate|conservative
| Strategy | Retention |
|---|---|
| aggressive | 0 days (delete all expired) |
| moderate | 60 days |
| conservative | 365 days |
Specialists Scheduling (Org-Scoped, Clerk Auth)
Specialists represent providers with scheduling properties: timezone, weekly recurring hours, and date-specific overrides.
Specialist CRUD
GET /v1/specialists
List specialists for the authenticated organization.
POST /v1/specialists
Create a specialist.
Body:
{
"displayName": "string (required)",
"timezone": "IANA timezone (required, e.g. America/New_York)",
"active": "boolean (default true)",
"metadata": "object (optional)"
}Response: 201 — Created specialist.
GET /v1/specialists/{id}
Get a single specialist by ID.
PATCH /v1/specialists/{id}
Update a specialist. Timezone change is blocked if appointment-type-specific overrides exist for this specialist.
Body: Any subset of the fields from POST /v1/specialists.
DELETE /v1/specialists/{id}
Delete a specialist.
Weekly Hours
GET /v1/specialists/{id}/weekly-hours
List recurring weekly availability blocks for a specialist.
Response:
{
"data": [
{
"id": "uuid",
"dayOfWeek": "mon",
"startTime": "09:00:00",
"endTime": "12:00:00"
}
]
}POST /v1/specialists/{id}/weekly-hours
Create a weekly hours block.
Body:
{
"dayOfWeek": "mon|tue|wed|thu|fri|sat|sun (required)",
"startTime": "HH:MM:SS (required)",
"endTime": "HH:MM:SS (required)"
}Times are wall-clock in the specialist's timezone.
PATCH /v1/specialists/{id}/weekly-hours/{weeklyHourId}
Update a weekly hours block.
Body:
{
"dayOfWeek": "mon|tue|wed|thu|fri|sat|sun (optional)",
"startTime": "HH:MM:SS (optional)",
"endTime": "HH:MM:SS (optional)"
}DELETE /v1/specialists/{id}/weekly-hours/{weeklyHourId}
Delete a weekly hours block.
POST /v1/specialists/{id}/weekly-hours/bulk-replace
Replace all weekly hours for a specialist in a single transaction.
Body:
{
"replacements": [
{
"dayOfWeek": "mon",
"slots": [
{ "startTime": "09:00:00", "endTime": "12:00:00" },
{ "startTime": "14:00:00", "endTime": "18:00:00" }
]
}
]
}Overrides
Date-specific availability overrides, scoped to an appointment type + specialist pair.
GET /v1/specialists/{id}/overrides?appointmentTypeId=
List overrides. appointmentTypeId is required.
Response:
{
"data": [
{
"id": "uuid",
"appointmentTypeId": "uuid",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"availability": true
}
]
}POST /v1/specialists/{id}/overrides
Create an override.
Body:
{
"appointmentTypeId": "uuid (required)",
"startDate": "ISO 8601 (required)",
"endDate": "ISO 8601 (required)",
"availability": "boolean (required)"
}availability=true adds availability (replaces weekly hours for the affected days). availability=false blocks availability.
PATCH /v1/specialists/{id}/overrides/{overrideId}
Update an override.
Body:
{
"startDate": "ISO 8601 (optional)",
"endDate": "ISO 8601 (optional)",
"availability": "boolean (optional)"
}DELETE /v1/specialists/{id}/overrides/{overrideId}
Delete an override. appointmentTypeId required as query param.
POST /v1/specialists/{id}/overrides/bulk-create
Batch create overrides. Max 100 overrides per request.
Body:
{
"overrides": [
{
"appointmentTypeId": "uuid",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"availability": true
}
]
}POST /v1/specialists/{id}/overrides/bulk-upsert
Replace overrides for specified dates. Supports two input formats.
Body:
{
"appointmentTypeId": "uuid",
"dates": ["2025-03-15", "2025-03-16"],
"overrides": [
{ "date": "2025-03-15", "startTime": "09:00", "endTime": "17:00", "availability": true },
{ "startDate": "2025-03-16T09:00:00Z", "endDate": "2025-03-16T17:00:00Z", "availability": true }
]
}DELETE /v1/specialists/{id}/overrides/cleanup?strategy=
Cleanup expired overrides across all appointment types for this specialist.
| Strategy | Retention |
|---|---|
| aggressive | 0 days (delete all expired) |
| moderate | 60 days |
| conservative | 365 days |
Availability
GET /v1/specialists/{id}/availability
Admin availability view. Max 90-day range.
Query Parameters:
| Param | Type | Required |
|---|---|---|
startDate | YYYY-MM-DD | Yes |
endDate | YYYY-MM-DD | Yes |
appointmentTypeId | UUID | No (includes appointment-type-specific overrides if provided) |
Response:
{
"specialistId": "uuid",
"timezone": "America/New_York",
"days": {
"2025-03-15": {
"weeklyHours": [{ "startTime": "09:00:00", "endTime": "17:00:00" }],
"overrides": [],
"effectiveSlots": ["2025-03-15T14:00:00Z", "2025-03-15T14:45:00Z"]
}
}
}GET /v1/specialists/{id}/appointment-types
List appointment types using this specialist, with priority info.
Response:
{
"data": [
{
"appointmentTypeId": "uuid",
"displayName": "string",
"priority": 1
}
]
}Public Booking Routes (No Auth)
GET /v1/appointment-types/{id}/details
Public appointment type info for the booking UI. Returns the appointment type with full specialist details (weekly hours, overrides) so the frontend can render a calendar.
Response:
{
"appointmentType": {
"id": "uuid",
"displayName": "string",
"slotDurationMinutes": 30,
"requiresTimeslot": true,
"requiresPayment": false,
"metadata": {}
},
"specialists": [
{
"id": "uuid",
"displayName": "string",
"timezone": "America/New_York",
"weeklyHours": [
{ "dayOfWeek": "mon", "startTime": "09:00:00", "endTime": "17:00:00" }
],
"overrides": []
}
]
}GET /v1/appointment-types/{id}/timeslots
Pooled available timeslots across all specialists assigned to this appointment type. Results cached in Redis (5 min default).
Query Parameters:
| Param | Type | Required |
|---|---|---|
specialistId | UUID | No (pooled mode if omitted) |
Response:
{
"startDate": "ISO UTC",
"endDate": "ISO UTC",
"slots": {
"2025-03-15T00:00:00Z": ["2025-03-15T09:00:00Z", "2025-03-15T09:45:00Z"]
},
"capacity": {
"2025-03-15T09:00:00Z": { "remaining": 2, "max": 3, "total": 3 }
},
"slotDurationMinutes": 30,
"streamUrl": "/v1/holds/stream?appointmentTypeId=..."
}POST /v1/appointment-types/{id}/book
Confirm a booking. Creates an appointment with status=booked.
Body:
{
"contactName": "string (1-200 chars, required)",
"contactEmail": "email (max 254 chars, required)",
"contactPhone": "string (1-50 chars, regex: [\\d+\\-\\s()]+, required)",
"patientId": "uuid (optional — links to existing patient)",
"clientId": "string (optional — for rate limiting)",
"holdId": "string (required for timeslot appointment types, forbidden for non-timeslot)"
}Flow:
- Validate request body
- Resolve clientId (query param, body, cookie, or generate UUID)
- Verify appointment type exists and belongs to an active org
- Check client rate limit (
slotsCooldownMinutes) - If timeslot appointment type: validate holdId exists, create appointment from hold
- If non-timeslot: auto-assign specialist by priority, create appointment
- Set rate limit after successful creation
- Return appointment
Response: 201
{
"appointment": {
"id": "uuid",
"appointmentTypeId": "uuid",
"specialistId": "uuid",
"status": "booked",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"contactName": "string",
"contactEmail": "string",
"contactPhone": "string",
"patientId": "uuid | null",
"clientId": "string",
"createdAt": "ISO 8601"
}
}Hold System (Public, No Auth)
All hold endpoints are public. They manage temporary slot reservations during the booking flow.
POST /v1/holds
Create/claim a hold on a timeslot.
Body:
{
"appointmentTypeId": "uuid (required)",
"slotStartDate": "ISO UTC (required)",
"specialistId": "uuid (optional — bypasses priority selection)",
"ttlMs": "number (optional, default 30000)",
"clientId": "string (optional)"
}Flow:
- Resolve clientId
- Check client rate limit
- If
specialistIdprovided: direct hold on that specialist's slot - Else: priority-based assignment with retry loop
- Propose specialist by priority
- Attempt
SET NX(atomic claim) - If slot already held: exclude specialist, retry with next candidate
- Until success or no candidates left
- Publish "hold" event to pub/sub
- Return hold payload
Response (success): 201
{
"holdId": "uuid",
"clientId": "string",
"specialistId": "uuid",
"appointmentTypeId": "uuid",
"slotStartDate": "ISO UTC",
"slotEndDate": "ISO UTC",
"holdExpiresAt": "ISO UTC"
}PATCH /v1/holds
Heartbeat — extend hold TTL.
Body:
{
"appointmentTypeId": "uuid (required)",
"specialistId": "uuid (required)",
"slotStartDate": "ISO UTC (required)",
"ttlMs": "number (optional)",
"clientId": "string (optional)"
}Response: 200 — Updated hold with new holdExpiresAt.
GET /v1/holds?appointmentTypeId=
List all active holds for an appointment type.
Query Parameters:
| Param | Type | Required |
|---|---|---|
appointmentTypeId | UUID | Yes |
Response:
{
"data": [
{
"holdId": "uuid",
"clientId": "string",
"specialistId": "uuid",
"appointmentTypeId": "uuid",
"slotStartDate": "ISO UTC",
"slotEndDate": "ISO UTC",
"holdExpiresAt": "ISO UTC"
}
]
}POST /v1/holds/check
Check if a client has a hold for a specific slot.
Body:
{
"appointmentTypeId": "uuid (required)",
"slotStartDate": "ISO UTC (required)",
"clientId": "string (optional)"
}Response:
{
"hasHold": true,
"hold": { "holdId": "uuid", "specialistId": "uuid", "holdExpiresAt": "ISO UTC" }
}POST /v1/holds/release-all
Release all holds for a client.
Body:
{
"clientId": "string (optional — resolved from cookie if omitted)",
"appointmentTypeId": "uuid (optional — scope release to one appointment type)"
}GET /v1/holds/stream
SSE stream for real-time hold updates. See 03-hold-system.md for full protocol.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
appointmentTypeId | UUID | required | Subscribe to this appointment type's events |
clientId | string | auto-resolved | Client identifier |
leaseMs | number | 900000 (15 min) | Connection lease, server-capped at 1 hour |
Event Types:
| Type | When | Data |
|---|---|---|
init | Connection established | appointmentTypeId, clientId |
connected | Subscription active | appointmentTypeId, clientId, timestamp |
hold | Slot claimed | Full HoldPayload + isOwnHold |
heartbeat | Hold extended | Full HoldPayload + isOwnHold |
release | Slot freed | Full HoldPayload + isOwnHold |
confirm | Booking confirmed | Full HoldPayload + isOwnHold |
ping | Health check | timestamp, connectionId |
end | Connection closing | reason, retryAfterMs |
Response Headers:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: noAppointments (Org-Scoped, Clerk Auth)
Appointments are managed through the Core API's existing appointment endpoints. The scheduling merge adds new fields and endpoints.
New Fields on Appointment
| Field | Type | Description |
|---|---|---|
appointmentTypeId | UUID | Links to the appointment type that created this appointment |
specialistId | UUID | The specialist assigned to this appointment |
status | string | booked, cancelled |
contactName | string (1-200 chars) | Patient contact name from booking |
contactEmail | string (max 254 chars) | Patient contact email from booking |
contactPhone | string (1-50 chars) | Patient contact phone from booking |
patientId | UUID, nullable | Links to a patient record (null until onboarded) |
clientId | string | Rate-limiting client identifier from the booking flow |
startDate | ISO 8601 | Appointment start time |
endDate | ISO 8601 | Appointment end time |
GET /v1/appointments
List appointments for the authenticated organization. Supports advanced query DSL for filtering, sorting, and pagination.
Query Parameters: Advanced query system (filters, sort, pagination).
Response:
{
"data": [Appointment],
"meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}GET /v1/appointments/{id}
Get an appointment with related appointment type and specialist.
Response:
{
"appointment": Appointment,
"appointmentType": AppointmentType,
"specialist": Specialist
}PATCH /v1/appointments/{id}
Update an appointment. Cancellation clears rate limits and invalidates the timeslot cache.
Body:
{
"status": "booked | cancelled (optional)",
"startDate": "ISO 8601 (optional)",
"endDate": "ISO 8601 (optional)",
"contactName": "string (optional)",
"contactEmail": "email (optional)",
"contactPhone": "string (optional)",
"patientId": "uuid (optional)"
}DELETE /v1/appointments/{id}
Soft-delete (marks as cancelled). Clears rate limits and invalidates cache.
POST /v1/appointments/{id}/cancel
Cancel an appointment. Fails if already cancelled. Clears rate limits and invalidates cache.
POST /v1/appointments/{id}/reschedule
Reschedule to a new time. Requires requiresTimeslot=true on the appointment type.
Body:
{ "startDate": "ISO 8601 (required)" }New endDate calculated from the appointment type's slotDurationMinutes.
POST /v1/appointments/{id}/onboard
Link an appointment to a patient record. Used after the patient completes onboarding forms.
Body:
{
"patientId": "uuid (required)"
}Response: 200 — Updated appointment with patientId set.
Flow:
- Validate that the appointment exists and belongs to the authenticated org
- Validate that the patient exists and belongs to the same org
- Set
patientIdon the appointment - Return updated appointment
GET /v1/appointments/calendar
Calendar view of appointments grouped by date.
Query Parameters:
| Param | Type | Description |
|---|---|---|
view | month|week|day | Month returns counts, week/day returns details |
startDate | ISO 8601 | Range start |
endDate | ISO 8601 | Range end |
specialistId | UUID (optional) | Filter to a specific specialist |
appointmentTypeId | UUID (optional) | Filter to a specific appointment type |
Response (month view):
{
"view": "month",
"totalCount": 42,
"data": { "2025-03-15": 3, "2025-03-16": 1 },
"meta": { "timestamp": "", "timezone": "UTC" }
}Response (week/day view):
{
"view": "week",
"totalCount": 7,
"data": {
"2025-03-15": [
{
"id": "uuid",
"appointmentTypeId": "uuid",
"specialistId": "uuid",
"status": "booked",
"startDate": "ISO 8601",
"endDate": "ISO 8601",
"contactName": "string",
"patientId": "uuid | null"
}
]
},
"meta": { "timestamp": "", "timezone": "UTC" }
}Org-Scoped Timeslots (Clerk Auth)
GET /v1/timeslots
Available timeslots. Results cached in Redis (5 min default). This endpoint is org-scoped and allows querying by specialist — useful for admin scheduling views.
Query Parameters:
| Param | Type | Required |
|---|---|---|
appointmentTypeId | UUID | Yes |
specialistId | UUID | No (pooled mode if omitted) |
Response:
{
"startDate": "ISO UTC",
"endDate": "ISO UTC",
"slots": {
"2025-03-15T00:00:00Z": ["2025-03-15T09:00:00Z", "2025-03-15T09:45:00Z"]
},
"capacity": {
"2025-03-15T09:00:00Z": { "remaining": 2, "max": 3, "total": 3 }
},
"slotDurationMinutes": 30,
"streamUrl": "/v1/holds/stream?appointmentTypeId=..."
}What Was Dropped from the Old Intakes Service
The following routes and systems from the standalone Intakes Node.js service are not carried into the merged Core API:
| Dropped | Reason |
|---|---|
GET/POST/PATCH/DELETE /admin/organizations | Org management is handled by the Core API's existing org system |
GET/POST/DELETE /api/api-keys, GET/POST/DELETE /admin/organizations/{id}/api-keys | API key auth replaced by the Core API's Clerk auth middleware |
GET /admin/health, GET /admin/health/metrics, GET /admin/health/metrics/stream | Health monitoring handled by the Core API's infrastructure |
GET /admin/health/subscribers, GET/POST /admin/health/cors | Redis and CORS diagnostics handled at the platform level |
POST /admin/email/test | Email testing integrated into the Core API's notification system |
| CORS middleware | Handled by the Core API's middleware stack or API gateway |
| API key authentication middleware | Replaced by Clerk session auth; org resolved from Clerk context |
/api/ route prefix | Replaced by /v1/ to match the Core API conventions |
All core scheduling functionality (appointment types, specialists, weekly hours, overrides, timeslots, holds, booking) is preserved with full parity.