Skip to content

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 termNew termReason
OpeningSpecialistRepresents a specialist with scheduling properties (timezone, weekly hours, overrides)
ScheduleAppointment TypeDefines booking rules + clinical configuration for a type of appointment
IntakeAppointmentRepresents 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.

HeaderValue
AuthorizationClerk 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 prefixAuthIP Rate Limit
GET /v1/appointment-types/{id}/detailsNone30/min
GET /v1/appointment-types/{id}/timeslotsNone30/min
POST /v1/appointment-types/{id}/bookNone10/hour
POST /v1/holdsNone20/min
PATCH /v1/holdsNone60/min
GET /v1/holdsNone30/min
POST /v1/holds/checkNone30/min
POST /v1/holds/release-allNone10/min
GET /v1/holds/streamNone10/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:

json
{
  "data": [AppointmentType],
  "meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}

POST /v1/appointment-types

Create an appointment type.

Body:

json
{
  "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:

json
{ "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:

json
{
  "data": [
    {
      "specialistId": "uuid",
      "displayName": "string",
      "priority": 1,
      "active": true
    }
  ]
}

POST /v1/appointment-types/{id}/specialists

Assign a specialist to this appointment type.

Body:

json
{ "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:

json
{
  "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

StrategyRetention
aggressive0 days (delete all expired)
moderate60 days
conservative365 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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "data": [
    {
      "id": "uuid",
      "appointmentTypeId": "uuid",
      "startDate": "ISO 8601",
      "endDate": "ISO 8601",
      "availability": true
    }
  ]
}

POST /v1/specialists/{id}/overrides

Create an override.

Body:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

StrategyRetention
aggressive0 days (delete all expired)
moderate60 days
conservative365 days

Availability

GET /v1/specialists/{id}/availability

Admin availability view. Max 90-day range.

Query Parameters:

ParamTypeRequired
startDateYYYY-MM-DDYes
endDateYYYY-MM-DDYes
appointmentTypeIdUUIDNo (includes appointment-type-specific overrides if provided)

Response:

json
{
  "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:

json
{
  "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:

json
{
  "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:

ParamTypeRequired
specialistIdUUIDNo (pooled mode if omitted)

Response:

json
{
  "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:

json
{
  "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:

  1. Validate request body
  2. Resolve clientId (query param, body, cookie, or generate UUID)
  3. Verify appointment type exists and belongs to an active org
  4. Check client rate limit (slotsCooldownMinutes)
  5. If timeslot appointment type: validate holdId exists, create appointment from hold
  6. If non-timeslot: auto-assign specialist by priority, create appointment
  7. Set rate limit after successful creation
  8. Return appointment

Response: 201

json
{
  "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:

json
{
  "appointmentTypeId": "uuid (required)",
  "slotStartDate": "ISO UTC (required)",
  "specialistId": "uuid (optional — bypasses priority selection)",
  "ttlMs": "number (optional, default 30000)",
  "clientId": "string (optional)"
}

Flow:

  1. Resolve clientId
  2. Check client rate limit
  3. If specialistId provided: direct hold on that specialist's slot
  4. 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
  5. Publish "hold" event to pub/sub
  6. Return hold payload

Response (success): 201

json
{
  "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:

json
{
  "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:

ParamTypeRequired
appointmentTypeIdUUIDYes

Response:

json
{
  "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:

json
{
  "appointmentTypeId": "uuid (required)",
  "slotStartDate": "ISO UTC (required)",
  "clientId": "string (optional)"
}

Response:

json
{
  "hasHold": true,
  "hold": { "holdId": "uuid", "specialistId": "uuid", "holdExpiresAt": "ISO UTC" }
}

POST /v1/holds/release-all

Release all holds for a client.

Body:

json
{
  "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:

ParamTypeDefaultDescription
appointmentTypeIdUUIDrequiredSubscribe to this appointment type's events
clientIdstringauto-resolvedClient identifier
leaseMsnumber900000 (15 min)Connection lease, server-capped at 1 hour

Event Types:

TypeWhenData
initConnection establishedappointmentTypeId, clientId
connectedSubscription activeappointmentTypeId, clientId, timestamp
holdSlot claimedFull HoldPayload + isOwnHold
heartbeatHold extendedFull HoldPayload + isOwnHold
releaseSlot freedFull HoldPayload + isOwnHold
confirmBooking confirmedFull HoldPayload + isOwnHold
pingHealth checktimestamp, connectionId
endConnection closingreason, retryAfterMs

Response Headers:

Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

Appointments (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

FieldTypeDescription
appointmentTypeIdUUIDLinks to the appointment type that created this appointment
specialistIdUUIDThe specialist assigned to this appointment
statusstringbooked, cancelled
contactNamestring (1-200 chars)Patient contact name from booking
contactEmailstring (max 254 chars)Patient contact email from booking
contactPhonestring (1-50 chars)Patient contact phone from booking
patientIdUUID, nullableLinks to a patient record (null until onboarded)
clientIdstringRate-limiting client identifier from the booking flow
startDateISO 8601Appointment start time
endDateISO 8601Appointment 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:

json
{
  "data": [Appointment],
  "meta": { "pagination": {}, "filters": {}, "sort": [], "timestamp": "" }
}

GET /v1/appointments/{id}

Get an appointment with related appointment type and specialist.

Response:

json
{
  "appointment": Appointment,
  "appointmentType": AppointmentType,
  "specialist": Specialist
}

PATCH /v1/appointments/{id}

Update an appointment. Cancellation clears rate limits and invalidates the timeslot cache.

Body:

json
{
  "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:

json
{ "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:

json
{
  "patientId": "uuid (required)"
}

Response: 200 — Updated appointment with patientId set.

Flow:

  1. Validate that the appointment exists and belongs to the authenticated org
  2. Validate that the patient exists and belongs to the same org
  3. Set patientId on the appointment
  4. Return updated appointment

GET /v1/appointments/calendar

Calendar view of appointments grouped by date.

Query Parameters:

ParamTypeDescription
viewmonth|week|dayMonth returns counts, week/day returns details
startDateISO 8601Range start
endDateISO 8601Range end
specialistIdUUID (optional)Filter to a specific specialist
appointmentTypeIdUUID (optional)Filter to a specific appointment type

Response (month view):

json
{
  "view": "month",
  "totalCount": 42,
  "data": { "2025-03-15": 3, "2025-03-16": 1 },
  "meta": { "timestamp": "", "timezone": "UTC" }
}

Response (week/day view):

json
{
  "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:

ParamTypeRequired
appointmentTypeIdUUIDYes
specialistIdUUIDNo (pooled mode if omitted)

Response:

json
{
  "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:

DroppedReason
GET/POST/PATCH/DELETE /admin/organizationsOrg management is handled by the Core API's existing org system
GET/POST/DELETE /api/api-keys, GET/POST/DELETE /admin/organizations/{id}/api-keysAPI key auth replaced by the Core API's Clerk auth middleware
GET /admin/health, GET /admin/health/metrics, GET /admin/health/metrics/streamHealth monitoring handled by the Core API's infrastructure
GET /admin/health/subscribers, GET/POST /admin/health/corsRedis and CORS diagnostics handled at the platform level
POST /admin/email/testEmail testing integrated into the Core API's notification system
CORS middlewareHandled by the Core API's middleware stack or API gateway
API key authentication middlewareReplaced by Clerk session auth; org resolved from Clerk context
/api/ route prefixReplaced 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.