Skip to content

Scheduling API Contracts

⚠️ NOT IMPLEMENTED. Verified against code 2026-08-02. None of the routes below exist. There is no internal/core/domain/scheduling/ package, no calendars table, and zero calendars.* / appointments.* / specialists.* permission rows in any migration — those codes live only in rbac-permissions.md prose. This is a contract to build against, not an API to call.

The scheduling system ports from the standalone restartix-intakes service, which is live in production today, into the API Go binary. All routes use the /v1/ prefix. Terminology changes:

Old termNew termReason
OpeningSpecialistRepresents a specialist with scheduling properties (timezone, weekly hours, overrides)
ScheduleCalendarThe bookable unit. Carries slot duration, gap, cooldown, min lead time, and the booking window
IntakeAppointmentRepresents a booked appointment; status booked, nullable patient_id, contact fields
franchiseorganizationTenancy

appointment_type is retired. Earlier drafts of this file used appointment_type / /v1/appointment-types for what the glossary and data-model.md Area 4 call calendars. Renamed 2026-08-02, before any migration could copy the losing name. appointment_type, opening, schedule, intake and franchise are forbidden terms in new code.

Cross-cutting requirements

Every route below inherits these. They are not optional and they are not per-endpoint decisions:

RequirementDetail
RLSEvery scheduling table carries organization_id UUID NOT NULL and RLS policies calling current_app_has_permission(resource, action). Junctions included. Repos use ConnFromContext(ctx)
P47Every per-org route group mounts middleware.RequireURLOrgMatchesScope("id"). Apply preemptively whether or not the endpoint caches today — without it, RLS hides one mismatched response and the cache propagates it
PermissionsRequirePermission gates every non-public route. The permission rows must be seeded in the same migration that creates the tables
AuditEvery state-changing mutation writes an audit_log row (actor, action, field-level changes, IP, status)
PaginationEvery list endpoint is server-paginated via apiquery — default limit ≤ 50, hard cap 500. No unbounded list endpoints, no client-side filtering
PickersSpecialist and calendar pickers are async typeahead (?q= + ?ids=), never pre-loaded. Queryable text columns get GIN trigram + immutable_unaccent
ClassificationPublic projections go through classification.Filter(record, target) (P39). Never hand-build the public field list — contact fields on a booking are pii_basic

Authentication

Clerk-Authenticated Routes (Admin / Specialist)

All org-scoped routes require the 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 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. The limiter is shippedinternal/core/ratelimit provides a Redis Store, a Policy type, Middleware, and the IPKey / PrincipalKey / URLParamKey extractors, with tests. Compose it per endpoint group; do not write a scheduling-specific limiter (the go/ratelimit_ip.go draft is superseded and does not compile). Public routes run on the AdminPool per P5, with the org resolved from the calendar, and every response projected through classification.Filter.

⚠️ Whether the public self-booking path ships at all is §8.10, OPEN. In leo the real creation surface is staff-side; patient bookings arrive from the external Intakes public UI. Shipping the public path exposes unauthenticated hold + book endpoints and their abuse surface. The routes are specified either way; the decision is whether they mount.

Route prefixAuthIP Rate Limit
GET /v1/calendars/{id}/detailsNone30/min
GET /v1/calendars/{id}/timeslotsNone30/min
POST /v1/calendars/{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

Calendars (Org-Scoped, Clerk Auth)

GET /v1/calendars

List calendars for the authenticated organization. Supports advanced query DSL for filtering, sorting, and pagination.

Query Parameters: Advanced query system (filters, sort, pagination).

Response:

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

POST /v1/calendars

Create a calendar.

Body:

json
{
  "offeringId": "uuid (required — FK to offerings)",
  "displayName": "string (required)",
  "slug": "string (required, unique per org)",
  "locationId": "uuid (optional, null = remote/telerehab)",
  "slotDurationMinutes": "int >= 1 (required)",
  "slotGapMinutes": "int >= 0 (default 0)",
  "slotsCooldownMinutes": "int >= 0 (default 1440)",
  "minLeadTimeMinutes": "int >= 0 (default 1440)",
  "horizonDays": "int >= 0 (default 0)",
  "requiresTimeslot": "boolean (default true)",
  "requiresPayment": "boolean (default false)",
  "assignmentStrategy": "priority | round_robin | manual (default priority)",
  "slotsOpenAt": "ISO 8601 (optional)",
  "slotsCloseAt": "ISO 8601 (optional)",
  "metadata": "object (optional)"
}

Booking window is horizon XOR explicit range (S9). Either horizonDays > 0 with both timestamps null, or horizonDays = 0 with both timestamps set. Any other combination is a 400, and it is also rejected by chk_calendars_window_xor_horizon at the database — leo enforces this only in a client-side save handler, which is how it acquired calendars with a zero-width window and no slots.

offeringId is a NOT NULL FK. It has no target until the F2.1 offerings stand-in ships (settled in scope 2026-08-02, not yet built).

Response: 201 — Created calendar.


GET /v1/calendars/{id}

Get a single calendar by ID.

Response:

json
{ "calendar": Calendar }

PATCH /v1/calendars/{id}

Update a calendar. Updates invalidate the timeslot cache.

Body: Any subset of the fields from POST /v1/calendars.


DELETE /v1/calendars/{id}

Delete a calendar. Invalidates the timeslot cache.


Specialist Assignment

GET /v1/calendars/{id}/specialists

List specialists assigned to this calendar, with priority info.

Response:

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

POST /v1/calendars/{id}/specialists

Assign a specialist to this calendar.

Body:

json
{ "specialistId": "uuid (required)", "priority": "int (required)" }

DELETE /v1/calendars/{id}/specialists/{specialistId}

Remove a specialist from this calendar. Invalidates cache.


PATCH /v1/calendars/{id}/specialists/reorder

Bulk reorder specialist priorities on this calendar.

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/calendars/{id}/overrides/cleanup

Delete expired overrides for all specialists on this calendar. ?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 calendar-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.

Semantics: per-day transactional replace. For each dayOfWeek present in the payload, delete that day's rows and insert the new ones, all in one transaction. Days absent from the payload are untouched. Port these semantics exactly as leo has them (openings.repository.ts:200-234 already loops per day inside a transaction) — a whole-table replace would silently wipe days the editor never opened.

Body:

json
{
  "replacements": [
    {
      "dayOfWeek": "mon",
      "slots": [
        { "startTime": "09:00:00", "endTime": "12:00:00" },
        { "startTime": "14:00:00", "endTime": "18:00:00" }
      ]
    }
  ]
}

Times are wall-clock in the specialist's scheduling_timezone. Overlapping slots within a day are rejected by the EXCLUDE USING gist constraint regardless of locationId — a specialist cannot be in two places at once (P40).


Overrides

Date-specific availability overrides.

⚠️ Override scoping is §8.2, OPEN. The calendarId parameter below reflects leo's per-schedule scoping (schedule_opening_overrides.schedule_id NOT NULL, with live production rows). The platform recommendation is calendar_id UUID NULL where NULL means "all calendars", which would make calendarId optional on these routes rather than required. Do not write the migration or freeze these signatures until the decision lands. See availability-engine.md → Override scoping.

Two semantics are settled regardless of scoping:

  • S1 — an override REPLACES a day, it never merges. Any override touching a local date causes that date's weekly hours to be skipped entirely.
  • S2 — a day blocked entirely is one availability=false row spanning the day. There is no separate "blocked" flag; "block Tuesday" is expressed as an override with no available intervals.

GET /v1/specialists/{id}/overrides?calendarId=

List overrides. calendarId required-or-optional per §8.2.

Response:

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

POST /v1/specialists/{id}/overrides

Create an override.

Body:

json
{
  "calendarId": "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. calendarId required as query param.


POST /v1/specialists/{id}/overrides/bulk-create

Batch create overrides. Max 100 overrides per request.

Body:

json
{
  "overrides": [
    {
      "calendarId": "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
{
  "calendarId": "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 calendars 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
calendarIdUUIDNo (includes calendar-specific overrides if provided)

Response:

json
{
  "specialistId": "uuid",
  "timezone": "Europe/Bucharest",
  "dateRange": {
    "requestedStart": "2025-03-01",
    "requestedEnd": "2025-09-01",
    "effectiveStart": "2025-03-01",
    "effectiveEnd": "2025-05-30"
  },
  "generatedAt": "2025-03-15T14:37:00Z",
  "days": {
    "2025-03-15": {
      "weeklyHours": [{ "startTime": "09:00:00", "endTime": "17:00:00" }],
      "overrides": [],
      "effectiveSlots": ["2025-03-15T07:00:00Z", "2025-03-15T07:45:00Z"]
    }
  }
}

The range is capped at 90 days. When the request exceeds it, the response is clamped and dateRange reports both the requested and effective bounds so the UI can grey out the remainder instead of silently showing an empty calendar. generatedAt is the S19 freshness stamp — a cached grid is detectable client-side.


GET /v1/specialists/{id}/calendars

List calendars using this specialist, with priority info.

Response:

json
{
  "data": [
    {
      "calendarId": "uuid",
      "displayName": "string",
      "priority": 1
    }
  ]
}

Public Booking Routes (No Auth)

GET /v1/calendars/{id}/details

Public calendar info for the booking UI. Returns the calendar with full specialist details (weekly hours, overrides) so the frontend can render a calendar.

Response:

json
{
  "calendar": {
    "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/calendars/{id}/timeslots

Pooled available timeslots across all specialists assigned to this calendar. 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?calendarId=..."
}

POST /v1/calendars/{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, required)",
  "patientId": "uuid (optional — links to existing patient)",
  "holdId": "string (required for timeslot calendars, forbidden for non-timeslot)"
}

clientId is NOT a body field. It is read from a server-signed HttpOnly cookie and never accepted from the request. Leo takes it from body > query > cookie > generated, validates none of it, and the dashboard regenerates it after each booking — which makes the 24h cooldown decorative. See hold-system.md → Booking client identity.

Flow:

  1. Validate request body
  2. Resolve the booking client id from the signed cookie; mint one if absent
  3. Verify the calendar exists, is published, and belongs to an active org
  4. Check min lead time (minLeadTimeMinutes) — on failure return the structured S11 error, not a boolean
  5. Check booking cooldown (slotsCooldownMinutes), keyed per calendar (S12) and failing open on Redis error (S13)
  6. If timeslot calendar: verify the hold still exists and belongs to this client, then create the appointment from it
  7. If non-timeslot: auto-assign specialist by priority + deterministic tiebreak (S16), create appointment with both dates NULL (S7 permits it)
  8. Insert. The EXCLUDE USING gist constraint is the authority — a 23P01 here means the hold was lost and the slot was taken; return 409, do not retry blindly
  9. Set the cooldown after the row commits
  10. Persist booking_client_id on the appointment
  11. Return appointment

Error responses:

StatusConditionBody
409Hold expired or slot taken between hold and insertslot_unavailable
422Slot inside the window but sooner than minLeadTimeMinutesS11 shape: { message, minLeadTimeMinutes, slotStart, earliestBookableAt }
429Booking cooldown active for this (org, calendar, client)remaining cooldown + earliest retry time
400Slot not aligned to the grid, or outside the booking windowslot_not_aligned / outside_registration_window

Response: 201

json
{
  "appointment": {
    "id": "uuid",
    "calendarId": "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.

clientId is never a request input on any of these routes. It is resolved server-side from a signed HttpOnly cookie and echoed in responses. Accepting it from a body, query string, or header would let any caller impersonate another client's holds and bypass the booking cooldown — which is what leo does today. ttlMs is likewise server-capped, not client-dictated.

Holds are advisory. The durable double-booking guarantee is the EXCLUDE USING gist constraint documented in hold-system.md.

POST /v1/holds

Create/claim a hold on a timeslot.

Body:

json
{
  "calendarId": "uuid (required)",
  "slotStartDate": "ISO UTC (required)",
  "specialistId": "uuid (optional — bypasses priority selection)",
  "ttlMs": "number (optional, default 30000, server-capped)"
}

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",
  "calendarId": "uuid",
  "slotStartDate": "ISO UTC",
  "slotEndDate": "ISO UTC",
  "holdExpiresAt": "ISO UTC"
}

PATCH /v1/holds

Heartbeat — extend hold TTL.

Body:

json
{
  "calendarId": "uuid (required)",
  "specialistId": "uuid (required)",
  "slotStartDate": "ISO UTC (required)",
  "ttlMs": "number (optional, server-capped)"
}

Response: 200 — Updated hold with new holdExpiresAt.


GET /v1/holds?calendarId=

List all active holds for a calendar.

Query Parameters:

ParamTypeRequired
calendarIdUUIDYes

Response:

json
{
  "data": [
    {
      "holdId": "uuid",
      "clientId": "string",
      "specialistId": "uuid",
      "calendarId": "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
{
  "calendarId": "uuid (required)",
  "slotStartDate": "ISO UTC (required)"
}

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
{
  "calendarId": "uuid (optional — scope release to one calendar)"
}

GET /v1/holds/stream

SSE stream for real-time hold updates. See hold-system.md for the full protocol; it rides the shipped internal/core/sse hub.

Query Parameters:

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

Event Types:

TypeWhenData
initConnection establishedcalendarId, clientId
connectedSubscription activecalendarId, 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)

⚠️ There are no "existing appointment endpoints." The appointments table does not exist. Every reference to it in current migrations is a forward-looking comment. Appointments are F5 and are specified in features/appointments/ plus appointments-substrate.md — not here. The routes below are retained only because they describe the scheduling↔appointments contract; F5 owns their final shape, including the status enum, the state machine, and the two-phase patient identity.

Two known divergences to reconcile when F5 is written, rather than copying from this page:

  • Status. The two-value booked | cancelled shown below is the leo-era shape. F5's substrate specifies a 9-status enum with cancelled split three ways (cancelled_by_patient / cancelled_by_clinic / cancelled_late), because the adherence denominator must exclude clinic-attributable cancellations.
  • Patient identity. F5 uses two-phase identity — patient_profile_id set at booking with patient_id NULL, and patient_id linked at onboarding — not a single nullable patientId.

Fields the scheduling domain contributes

FieldTypeDescription
calendarIdUUID NOT NULLThe calendar this appointment was booked through
specialistIdUUIDThe specialist assigned by the assignment engine
contactNamestring (1-200 chars)Contact name from booking (pii_basic)
contactEmailstring (max 254 chars)Contact email from booking (pii_basic)
contactPhonestring (1-50 chars)Contact phone from booking (pii_basic)
bookingClientIdstringServer-signed booking client identifier; the cooldown key survives a cookie clear because of this column
startedAt / endedAtTIMESTAMPTZ NULLBoth NULL for non-timeslot bookings, or both set with end > start (S7). Participates in the double-booking exclusion constraint (S6)

Every column above needs a data-classification.md entry in the migration's own PR — make check fails otherwise.


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 calendar and specialist.

Response:

json
{
  "appointment": Appointment,
  "calendar": Calendar,
  "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 calendar.

Body:

json
{ "startDate": "ISO 8601 (required)" }

New endDate calculated from the calendar'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
calendarIdUUID (optional)Filter to a specific calendar

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",
        "calendarId": "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
calendarIdUUIDYes
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?calendarId=..."
}

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

DroppedReason
GET/POST/PATCH/DELETE /admin/organizationsOrg management is handled by the API's existing org system
GET/POST/DELETE /api/api-keys, GET/POST/DELETE /admin/organizations/{id}/api-keysAPI key auth replaced by the API's Clerk auth middleware
GET /admin/health, GET /admin/health/metrics, GET /admin/health/metrics/streamHealth monitoring handled by the 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 API's notification system
CORS middlewareHandled by the 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 API conventions
Caller-supplied clientIdServer-signed HttpOnly cookie; persisted to appointments.booking_client_id
Wildcard SCAN-based cache and cooldown invalidationExplicit per-key invalidation (P45); a SCAN per cancellation does not survive 20k patients
Org-less Redis keyscache.OrgResource(orgID, …) on every hold, stream, cooldown and timeslot key (C13)

All core scheduling functionality — calendars, specialists, weekly hours, overrides, timeslots, holds, booking — is carried across. The data model is not: leo's schema, tenancy model, and naming are explicitly rejected. See leo-port-map.md §5 for the sixteen foundation conflicts and §7 for the anti-patterns the port must not carry.