Skip to content

API Reference

All webhook and admin API endpoints -- Updated for v4.3.0 (includes SMS Booking Confirmation)


Base Information

Item Value
Production URL https://api.vitaravox.ca
Local Dev URL http://localhost:3002
Protocol HTTPS (TLS 1.2+)
Format JSON
Version v4.3.0

Authentication

Webhook API (Vapi Tool Calls)

Vapi webhook endpoints support multi-auth (any one method is sufficient):

Method Header Description
Bearer Token Authorization: Bearer <secret> Primary method. Token matches VAPI_WEBHOOK_SECRET env var
HMAC-SHA256 X-Vapi-Signature: <hmac-sha256-signature> Vapi's built-in webhook signing. 5-minute replay window via x-vapi-timestamp
API Key X-API-Key: <api-key> Legacy/admin API key

All comparisons use constant-time (crypto.timingSafeEqual) to prevent timing attacks. In development mode (non-production), auth is skipped if no secret is configured.

WEBHOOK AUTHENTICATION CASCADE (any-one-passes)

Incoming POST /api/vapi/*
┌─────────────────────────────┐
│ Check Bearer Token          │──► Match? ──► AUTHENTICATED
│ Authorization: Bearer <tok> │
└────────────┬────────────────┘
             │ No match
┌─────────────────────────────┐
│ Check HMAC-SHA256           │──► Valid? ──► AUTHENTICATED
│ X-Vapi-Signature header     │    (5-min replay window)
│ crypto.timingSafeEqual      │
└────────────┬────────────────┘
             │ No match
┌─────────────────────────────┐
│ Check API Key               │──► Match? ──► AUTHENTICATED
│ X-API-Key header            │
└────────────┬────────────────┘
             │ No match
        401 Unauthorized

Admin API (JWT)

Authorization: Bearer <jwt-access-token>
Content-Type: application/json

Obtain tokens via POST /api/auth/login with email + password.

JWT ADMIN AUTHENTICATION

POST /api/auth/login {email, password}
┌─────────────────────────┐
│ bcrypt.compare(password)│──► Fail (5x) ──► 15-min lockout
└────────────┬────────────┘
             │ Success
┌─────────────────────────┐     ┌─────────────────────┐
│ Issue JWT Access Token  │     │ Issue Refresh Token  │
│ (1-hour, HS256)         │     │ (7-day)              │
└────────────┬────────────┘     └──────────┬──────────┘
             │                             │
             ▼                             │
     Subsequent API calls                  │
     Authorization: Bearer <jwt>           │
             │                             │
             ▼ (on 401 expired)            │
     Auto-refresh ◄───────────────────────┘
     POST /api/auth/refresh

Auth Middleware Levels

Middleware Description
authMiddleware JWT token validation. Required for all /api/clinic/* and /api/admin/* routes
requireRole('vitara_admin') JWT + must have vitara_admin role
requireClinicAccess JWT + user must belong to the clinic being accessed
vapiWebhookAuth HMAC/Bearer/API-key authentication for Vapi webhooks
None Public endpoints (/health, /api/oscar/health)

Health & Status

GET /health

Health check endpoint. No authentication required. Mounted directly on the Express app (not under /api).

Response:

{
  "status": "healthy",
  "timestamp": "2026-01-12T15:30:00.000Z",
  "version": "4.0.1",
  "components": {
    "database": { "healthy": true, "latency_ms": 2 },
    "oscar": { "healthy": true, "latency_ms": 45 }
  }
}

HTTP status codes: 200 for healthy or degraded, 503 for down.


GET /api/oscar/health

OSCAR Bridge health check. No authentication required (mounted before authMiddleware for monitoring).

Response:

{
  "status": "ok",
  "message": "Oscar Bridge reachable"
}

Error Response (500):

{
  "status": "error",
  "message": "Oscar Bridge unreachable"
}


Vapi Webhook (Tool Calls)

Single Entry Point

All Vapi tool calls arrive at the same handler (POST /api/vapi/ or POST /api/vapi/:path). Routing is by function name inside the toolCalls array, not by URL path. The :path wildcard catches tool-specific URLs that Vapi sends (e.g., /api/vapi/search-patient), but the server ignores the URL path and routes by toolCall.function.name.

Legacy URL /vapi-webhook is also supported for backward compatibility.

Auth: vapiWebhookAuth (HMAC-SHA256 / Bearer / API Key)

Request envelope (all tools):

{
  "message": {
    "type": "tool-calls",
    "toolCalls": [
      {
        "id": "tc_abc123",
        "type": "function",
        "function": {
          "name": "search_patient_by_phone",
          "arguments": { ... }
        }
      }
    ],
    "call": {
      "id": "call_xyz",
      "customer": { "number": "+12367770690" },
      "phoneNumber": { "number": "+16045558888" },
      "phoneNumberId": "phone-abc123"
    }
  }
}

Response envelope:

{
  "results": [
    {
      "toolCallId": "tc_abc123",
      "result": { ... }
    }
  ]
}

Clinic Resolution

Webhook requests resolve the clinic through:

metadata.clinicId -> call.phoneNumber.number -> call.phoneNumberId (Vapi phone cache) -> clinicService.findClinicByVapiPhone()
  1. Check metadata.clinicId (test override)
  2. Check call.phoneNumber.number (the Vapi number that received the call)
  3. If empty (Telnyx BYO numbers), resolve via phoneNumberId from a cached Vapi API lookup
  4. Look up clinic by phone number in the database

If resolution fails, returns HTTP 400.


search_patient_by_phone

Search patient by caller phone number. The server ignores the LLM's phone argument and uses the real caller phone from call.customer.number (Telnyx metadata).

Function aliases: search_patient_by_phone

Arguments:

{
  "phone": "+16045551234"
}

Server overrides the phone argument

The LLM does not know the caller's real phone number. The server extracts it from call.customer.number and uses that instead of whatever the LLM sends.

Response (Found):

{
  "found": true,
  "patient": {
    "id": "12345",
    "firstName": "John",
    "lastName": "Smith",
    "dateOfBirth": "1985-03-15",
    "phone": "604-555-1234"
  },
  "message": "Found patient: John Smith"
}

Response (Not Found):

{
  "found": false,
  "message": "No patient found with that phone number"
}


search_patient

Search patient by name and date of birth.

Function aliases: search_patient, searchPatient

Arguments:

{
  "name": "John Smith",
  "dateOfBirth": "1985-03-15"
}

Parameter Type Required Description
name string Yes Patient name (single field, not separate first/last)
dateOfBirth string No Date of birth for disambiguation

Response (Found):

{
  "found": true,
  "patient": {
    "id": "12345",
    "firstName": "John",
    "lastName": "Smith",
    "dateOfBirth": "1985-03-15"
  },
  "message": "Found patient: John Smith"
}

Response (Not Found):

{
  "found": false,
  "message": "No patient found with that name and date of birth"
}

Single patient return

Unlike the previous docs which showed an array, this tool returns a single patient (the first match), not an array.


get_patient

Get full patient details by patient/demographic ID.

Function aliases: get_patient, getPatientDetails

Arguments:

{
  "patientId": "12345"
}

Parameter Type Required Description
patientId string Yes (either) Patient ID
demographicId number Yes (either) OSCAR demographic number (alternative to patientId)

Parameter naming

Accepts patientId (string) or demographicId (number). Does not accept demographicNo.

Response (Found):

{
  "found": true,
  "patient": {
    "id": "12345",
    "firstName": "John",
    "lastName": "Smith",
    "dateOfBirth": "1985-03-15",
    "sex": "M",
    "phone": "604-555-1234"
  }
}

Response (Not Found):

{
  "found": false,
  "message": "Patient 12345 not found"
}


getUpcomingAppointments

Get upcoming appointments for a patient (next 30 days).

Function aliases: getUpcomingAppointments

Stub implementation

This handler currently returns an empty array. Patient-based appointment lookup is not yet connected to the EMR adapter.

Arguments:

{
  "patientId": "12345"
}

Response:

{
  "appointments": [],
  "message": "Checking appointments for patient 12345 from 2026-02-17 to 2026-03-19"
}


get_clinic_info

Get clinic configuration for the current call. The clinicId argument is ignored -- the server resolves the clinic from call metadata (phone number).

Function aliases: get_clinic_info, getClinicSettings

Arguments:

{}

Response:

{
  "acceptingNewPatients": true,
  "businessHours": {
    "monday": { "open": "09:00", "close": "17:00" },
    "tuesday": { "open": "09:00", "close": "17:00" }
  },
  "isOpen": true,
  "transferEnabled": true,
  "handoffPhone": "+16045550100",
  "customGreeting": "Welcome to Downtown Medical Clinic",
  "customGreetingZh": null,
  "defaultProviderId": "100001",
  "supportedLanguages": ["en", "zh"],
  "appointmentTypeMappings": null,
  "message": "Clinic is currently open"
}

No clinicId in request

Unlike previous docs which showed { clinicId: "..." }, the server resolves clinic from call metadata. The clinicId argument is ignored.


get_providers

Get list of providers for the clinic on the current call.

Function aliases: get_providers, getProviders

Arguments: None required (clinic resolved from call metadata).

Response:

{
  "providers": [
    {
      "id": "100001",
      "name": "Dr. Sarah Chen",
      "specialty": "General Practice"
    },
    {
      "id": "100002",
      "name": "Dr. Michael Wong",
      "specialty": "General Practice"
    }
  ],
  "message": "Found 2 providers"
}

Field name differences from previous docs

Fields are id, name, specialty (not providerNo, displayName, acceptingNewPatients).


get_available_slots

Get available time slots for a provider on a specific date. Returns up to 5 slots.

Function aliases: get_available_slots, getAvailableSlots

Arguments:

{
  "providerId": "100001",
  "startDate": "2026-02-17"
}

Parameter Type Required Description
providerId string Yes Provider ID
startDate string Yes Date to check (YYYY-MM-DD)

Response (Slots available):

{
  "date": "2026-02-17",
  "slots": [
    { "time": "09:00", "display": "9 AM" },
    { "time": "09:30", "display": "9:30 AM" },
    { "time": "10:00", "display": "10 AM" }
  ],
  "hasAvailability": true,
  "message": "Found 3 available slots on 2026-02-17"
}

Response (No availability):

{
  "date": "2026-02-17",
  "slots": [],
  "hasAvailability": false,
  "message": "No availability on 2026-02-17"
}


find_earliest_appointment

Find the earliest available appointment slot. Returns exactly one slot to prevent decision paralysis in voice interactions.

Function aliases: find_earliest_appointment, findEarliestAppointment

Arguments:

{
  "providerId": "100001",
  "providerName": "Dr. Chen",
  "startDate": "2026-02-06",
  "endDate": "2026-02-20",
  "timeOfDay": "morning",
  "excludeDates": ["2026-02-07", "2026-02-10"]
}

Parameter Type Required Description
providerId string No Provider ID. Non-numeric values ("any", "任何") treated as "search all providers"
providerName string No Human name (e.g., "Dr. Chen"). Server fuzzy-matches against provider list
startDate string No ISO date. Server clamps past dates to today
endDate string No ISO date. Limits search window (default: 30 days from start)
timeOfDay string No "morning" (before 12:00) or "afternoon" (12:00+)
excludeDates string[] No Dates to skip (when patient rejects a slot)

Response (Found):

{
  "found": true,
  "slot": {
    "date": "2026-02-07",
    "time": "09:30",
    "providerId": "100001",
    "providerName": "Dr. Sarah Chen",
    "display": "Friday, February 7th at 9:30 AM"
  },
  "message": "Friday, February 7th at 9:30 AM with Dr. Sarah Chen"
}

Response (Not Found):

{
  "found": false,
  "message": "No availability found in the next 30 days"
}


findAppointmentByPreference

Find appointments matching a natural language preference. Returns up to 3 slots.

Function aliases: findAppointmentByPreference

Arguments:

{
  "patientId": "12345",
  "preference": "next Wednesday morning",
  "providerId": "100001"
}

Parameter Type Required Description
patientId string Yes Patient ID
preference string Yes Natural language time preference (e.g., "next Wednesday morning")
providerId string No Specific provider (searches up to 3 if omitted)

Response (Found):

{
  "found": true,
  "slots": [
    {
      "date": "2026-02-19",
      "time": "09:00",
      "providerId": "100001",
      "providerName": "Dr. Sarah Chen",
      "display": "Wednesday, February 19th at 9 AM"
    }
  ],
  "preference": "next Wednesday morning",
  "message": "Found 1 slot(s) matching \"next Wednesday morning\""
}

Response (Not Found):

{
  "found": false,
  "preference": "next Wednesday morning",
  "message": "No appointments found for \"next Wednesday morning\". Would you like to try a different time?"
}


check_appointments

Check a patient's existing appointments. Uses BookingEngine for enriched display.

Function aliases: check_appointments, getPatientAppointments

Arguments:

{
  "patientId": "12345",
  "startDate": "2026-02-10",
  "endDate": "2026-08-10"
}

Parameter Type Required Description
patientId string Yes (either) Patient ID
demographicId number Yes (either) OSCAR demographic number (alternative)
startDate string No Start of range (default: today)
endDate string No End of range (default: 90 days from today)

Response:

{
  "appointments": [
    {
      "id": "99001",
      "date": "2026-02-20",
      "time": "09:30",
      "providerName": "Dr. Sarah Chen",
      "reason": "General appointment",
      "displayDate": "Thursday, February 20th",
      "displayTime": "9:30 AM"
    }
  ],
  "count": 1,
  "message": "Found 1 upcoming appointment(s)"
}

Response (None found):

{
  "appointments": [],
  "count": 0,
  "message": "No upcoming appointments found"
}


create_appointment

Book a new appointment.

Function aliases: create_appointment, bookAppointment

Arguments:

{
  "patientId": "12345",
  "providerId": "100001",
  "appointmentTime": "2026-02-14T09:30:00",
  "appointmentType": "B",
  "reason": "Prescription refill",
  "language": "en"
}

Parameter Type Required Description
patientId string Yes (either) Patient ID
demographicId number Yes (either) OSCAR demographic number (alternative)
providerId string Yes Provider number
appointmentTime string Yes (either) ISO 8601 datetime, date+time, or bare time
startTime string Yes (either) Alternative name for appointmentTime
appointmentType string No Validated against clinic config or defaults ['B','2','3','P']. Invalid defaults to 'B'
reason string No Visit reason (default: "General appointment")
language string No en or zh. Cached for call log via callMetadataCache
smsConsent boolean No Whether patient consented to SMS confirmation (opt-out model: default true unless declined)

Server-side behaviors:

  • Robust time parsing: ISO 8601 (2026-02-06T09:00:00), date+time (2026-02-06 09:00), bare time (09:00 uses today)
  • Past-date clamp: If the LLM sends a date in the past, the server clamps it to today before calling OSCAR
  • appointmentType validated against DB-driven list or defaults ['B', '2', '3', 'P']
  • language and patientId cached via setCallMetadata for end-of-call log
  • Uses BookingEngine for slot collision checking
  • Advisory lock fail-safe: acquireAdvisoryLock returns false on error (not true), preventing double-booking under lock degradation
  • SMS trigger: On success, if smsConsent !== false and clinic has SMS enabled, fires fireSmsBehindWebhook() via setImmediate() (non-blocking)

Response (Success):

{
  "success": true,
  "appointmentId": "99001",
  "message": "Appointment booked for Thursday, February 14th at 9:30 AM",
  "smsSent": true
}

Response (Failure):

{
  "success": false,
  "message": "Unable to book appointment. Please try a different time."
}


update_appointment

Reschedule an existing appointment. Strategy: books the new appointment first, then cancels the old one (safer than cancel-first).

Function aliases: update_appointment

Arguments:

{
  "appointmentId": "99001",
  "newStartTime": "2026-02-15T14:00:00",
  "newProviderId": "100002",
  "patientId": "12345"
}

Parameter Type Required Description
appointmentId string Yes Existing appointment ID to reschedule
newStartTime string Yes (either) ISO 8601 datetime for new slot
datetime string Yes (either) Legacy name for newStartTime
newProviderId string Yes New provider ID
providerId string No Legacy name for newProviderId
patientId string No If not provided, server looks up from the original appointment
demographicId number No Alternative to patientId
reason string No Reason (default: "Rescheduled appointment")
smsConsent boolean No Whether patient consented to SMS confirmation

SMS trigger: On success, fires SMS for the new appointment (same behavior as create_appointment).

Response (Success):

{
  "success": true,
  "appointmentId": "99002",
  "message": "Appointment rescheduled to Saturday, February 15th at 2 PM",
  "smsSent": true
}

Response (Success with warning):

{
  "success": true,
  "appointmentId": "99002",
  "warning": true,
  "message": "New appointment booked for Saturday, February 15th at 2 PM, but we could not cancel your old appointment automatically. Please let our staff know."
}

Response (Failure):

{
  "success": false,
  "message": "Unable to book the new appointment. Your existing appointment is unchanged."
}


cancel_appointment

Cancel an appointment.

Function aliases: cancel_appointment, cancelAppointment

Arguments:

{
  "appointmentId": "99001",
  "reason": "patient requested"
}

Parameter Type Required Description
appointmentId string Yes Appointment to cancel
reason string No Cancellation reason
smsConsent boolean No Whether patient consented to SMS confirmation

SMS trigger: On success, fires cancellation SMS (different template from booking).

Response (Success):

{
  "success": true,
  "message": "Appointment has been cancelled",
  "smsSent": true
}

Response (Failure):

{
  "success": false,
  "message": "Unable to cancel appointment"
}


register_new_patient

Register a new patient in OSCAR EMR.

Function aliases: register_new_patient, registerPatient

NOT_SUPPORTED via SOAP

OSCAR's DemographicService SOAP API has no addDemographic method. Patient registration cannot be performed via the SOAP adapter. This endpoint requires either the OAuth 1.0a REST API fallback or the REST bridge. When emrType is set to oscar-soap, registration requests will fall back to the bridge adapter automatically.

Arguments:

{
  "firstName": "Jane",
  "lastName": "Doe",
  "dateOfBirth": "1990-05-20",
  "gender": "F",
  "phone": "6045559876",
  "address": "789 Oak St",
  "city": "Vancouver",
  "postalCode": "V6C 3C3",
  "province": "BC",
  "healthCardNumber": "9123456789",
  "healthCardType": "BC",
  "language": "en",
  "email": "jane.doe@email.com"
}

Parameter Type Required Description
firstName string Yes Patient first name
lastName string Yes Patient last name
dateOfBirth string Yes YYYY-MM-DD format
gender / sex string Yes M, F, or O (both field names accepted)
phone string Yes 10-digit phone number
address string No Street address
city string No City
postalCode string No Postal code
province string No Province (default BC)
healthCardNumber / hin string No Health card number
healthCardType string No BC, OUT_OF_PROVINCE, or PRIVATE. Maps to OSCAR: BC->BC, OUT_OF_PROVINCE->OT, PRIVATE->PR
language string No en or zh. Cached for call log
email string No Email address

Response (Success):

{
  "success": true,
  "patientId": "12346",
  "message": "Welcome Jane! You have been registered successfully."
}

Response (Failure):

{
  "success": false,
  "message": "Unable to complete registration. Please try again or speak with our staff."
}


checkNewPatientAcceptance

Check if the clinic accepts new patients.

Function aliases: checkNewPatientAcceptance

Arguments: None required (clinic resolved from call metadata).

Response:

{
  "accepting": true,
  "waitlistEnabled": true,
  "message": "Clinic is accepting new patients"
}


add_to_waitlist

Add a patient to the new patient waitlist.

Function aliases: add_to_waitlist, addToWaitlist

Arguments:

{
  "firstName": "Jane",
  "lastName": "Doe",
  "phone": "604-555-9876"
}

Parameter Type Required Description
firstName string Yes Patient first name
lastName string Yes Patient last name
phone string Yes Phone number

No clinicId in request

Unlike previous docs which showed clinicId, email, and notes, the server only takes firstName, lastName, and phone. The clinicId is resolved from call metadata.

Response (Success):

{
  "success": true,
  "message": "Jane Doe has been added to the waitlist. We will contact you when space becomes available."
}

Response (Failure):

{
  "success": false,
  "message": "Unable to add to waitlist"
}


transfer_call

Transfer call to clinic staff.

Function aliases: transfer_call, transferToHuman

Arguments:

{
  "reason": "medical_question",
  "context": "Patient asking about medication interactions"
}

Response:

{
  "transfer": true,
  "reason": "medical_question",
  "message": "Transferring to staff member"
}

Response format differs from previous docs

Returns { transfer: true, reason, message }, not { success, action, destination }.


SMS Booking Confirmation

SMS confirmations are not a separate tool call. They are automatically triggered by create_appointment, cancel_appointment, and update_appointment when the caller provides smsConsent: true (opt-out model).

How it works:

  1. Voice agent asks patient about SMS during the booking flow
  2. Patient's response is captured as smsConsent parameter on the tool call
  3. On successful booking/cancel/reschedule, server fires SMS via setImmediate() (non-blocking)
  4. SMS is sent via platform-level Telnyx API with per-clinic sender numbers
  5. smsSent: true is included in the tool response if SMS was triggered
  6. CallLog.smsConsent and CallLog.smsSentAt track consent and delivery

Guard chain (6 checks in sms.service.ts):

# Guard Failure Result
1 Telnyx API key configured Skip silently
2 smsConsent === true Skip silently
3 Valid phone number Log warning, skip
4 Clinic config exists Log warning, skip
5 smsEnabled === true Skip silently
6 Sender number configured Log warning, skip

6 templates (EN/ZH for each action):

Action EN Template ZH Template
Book smsTemplateBookEn smsTemplateBookZh
Cancel smsTemplateCancelEn smsTemplateCancelZh
Reschedule smsTemplateRescheduleEn smsTemplateRescheduleZh

Templates use {variable} placeholders (e.g., {patientName}, {date}, {time}, {providerName}, {clinicName}). Clinics can set custom templates; defaults are built-in.

SMS Config API Endpoints:

Method Path Auth Description
GET /api/clinic/sms-config JWT Get SMS configuration for current clinic
PUT /api/clinic/sms-config JWT Update SMS settings (smsEnabled, smsSenderNumber, templates)
POST /api/clinic/sms/test JWT Send a test SMS to verify configuration

log_call_metadata

Log call metadata for analytics. In v3.0, this is called by every role agent (Booking, Modification, Registration) instead of a separate Confirmation agent.

Function aliases: log_call_metadata, logCallSummary

Arguments:

{
  "callOutcome": "booked",
  "language": "en",
  "demographicId": 12345,
  "appointmentId": 99001
}

Parameter Type Required Description
callOutcome string Yes booked, rescheduled, cancelled, registered, waitlisted, no_action, clinic_info, transferred
action string No Legacy name for callOutcome
outcome string No Legacy name for callOutcome
language string No en or zh (default: en)
demographicId number No Patient demographic number
patientId string No Alternative to demographicId
appointmentId number No Related appointment ID

Server-side behavior (callMetadataCache):

The log_call_metadata tool caches its data in an in-memory Map keyed by callId:

setCallMetadata(callId, { language, outcome, demographicId, appointmentId })

When the end-of-call-report webhook fires, saveCallLog calls getCallMetadata(callId) and merges the cached data into the database record.

Fallback caching: create_appointment and register_new_patient also call setCallMetadata with language and demographicId.

Response:

{
  "logged": true
}

Response format

Returns { logged: true }, not { success: true, message: "..." }.


End-to-End Booking Flow

The following diagram shows how the webhook tools chain together during a typical voice booking call:

END-TO-END BOOKING FLOW (voice call sequence)

Patient calls ──► Router ──► Patient-ID agent
                         search_patient_by_phone
                         (server uses real caller #)
                              Patient found?
                              ├─ YES ──► detect intent
                              │          ├─ "book"   ──► Booking agent
                              │          ├─ "cancel"  ──► Modification agent
                              │          └─ "check"   ──► Modification agent
                              └─ NO ───► Registration agent

BOOKING AGENT FLOW:
┌──────────────────────────────────────────────────────────┐
│ 1. get_providers           → list available doctors      │
│ 2. find_earliest_appointment → one slot returned         │
│    (patient picks date/time/doctor preferences)          │
│ 3. Patient confirms slot                                 │
│ 4. create_appointment      → BookingEngine:              │
│    ├─ getTrueAvailability  (overlap + buffer check)      │
│    ├─ acquireAdvisoryLock  (race prevention)              │
│    ├─ adapter.createAppointment (OSCAR write)            │
│    └─ releaseLock                                        │
│ 5. log_call_metadata       → cache outcome for call log  │
│ 6. Agent confirms to patient, call ends                  │
└──────────────────────────────────────────────────────────┘

Other Webhook Message Types

The webhook handler also processes non-tool-call messages from Vapi:

Message Type Handler Response
assistant-request Returns assistant ID for squad routing { assistantId: "..." }
status-update Acknowledges call status changes { received: true }
end-of-call-report Saves call log to database (transcript, cost, duration) { received: true }
transfer-destination-request Resolves transfer phone from clinic config { destination: { type: "number", number: "+1..." } }
hang, speech-update, transcript, conversation-update, model-output, voice-input Acknowledged, not processed { received: true }

Auth Endpoints

All auth endpoints are mounted at /api/auth/.

Method Endpoint Auth Description
POST /api/auth/login None Login, returns JWT access + refresh tokens
POST /api/auth/refresh None Renew access token using refresh token
GET /api/auth/me JWT Get current user profile
POST /api/auth/logout JWT Invalidate session

POST /api/auth/login

Request (validated with loginSchema):

{
  "email": "admin@clinic.com",
  "password": "securePassword123"
}

Response (Success):

{
  "success": true,
  "data": {
    "accessToken": "eyJ...",
    "refreshToken": "abc...",
    "user": {
      "id": "user-uuid",
      "email": "admin@clinic.com",
      "role": "clinic_admin",
      "clinicId": "clinic-uuid"
    }
  }
}

Response (401):

{
  "success": false,
  "message": "Invalid email or password"
}

POST /api/auth/refresh

Request (validated with refreshTokenSchema):

{
  "refreshToken": "abc..."
}

Response:

{
  "success": true,
  "data": {
    "accessToken": "eyJ...",
    "refreshToken": "new-refresh-token"
  }
}

GET /api/auth/me

Auth: JWT

Response:

{
  "success": true,
  "data": {
    "id": "user-uuid",
    "email": "admin@clinic.com",
    "role": "clinic_admin",
    "clinicId": "clinic-uuid"
  }
}

POST /api/auth/logout

Auth: JWT

Response:

{
  "success": true,
  "message": "Logged out successfully"
}


Clinic Manager Endpoints

All clinic endpoints require JWT authentication (authMiddleware). Some additionally require requireClinicAccess.

GET /api/clinic/me

Get current user's clinic details.

Auth: JWT

Response:

{
  "success": true,
  "data": { ... }
}


PUT /api/clinic/me

Update current user's clinic details.

Auth: JWT Validation: updateClinicSchema

Request: Clinic fields to update (per schema).

Response:

{
  "success": true,
  "data": { ... }
}


GET /api/clinic/stats

Get dashboard statistics for current user's clinic.

Auth: JWT


GET /api/clinic/calls

Get recent calls for current user's clinic.

Auth: JWT


GET /api/clinic/settings

Get clinic settings.

Auth: JWT

No PUT for /api/clinic/settings

Only GET exists. Use PUT /api/clinic/me for clinic data or PUT /api/clinic/clinical-settings for clinical config.


GET /api/clinic/analytics

Get call analytics for current user's clinic.

Auth: JWT


GET /api/clinic/call-history

Get paginated call logs for current user's clinic.

Auth: JWT

Query Parameters:

Parameter Type Description
page number Page number
pageSize number Results per page
startDate string Filter start date
endDate string Filter end date

Waitlist Endpoints

Method Endpoint Auth Validation Description
GET /api/clinic/waitlist JWT -- List waitlist entries
GET /api/clinic/waitlist/stats JWT -- Get waitlist statistics
POST /api/clinic/waitlist JWT addToWaitlistSchema Add to waitlist
PUT /api/clinic/waitlist/:id JWT -- Update waitlist entry
DELETE /api/clinic/waitlist/:id JWT -- Remove from waitlist
POST /api/clinic/waitlist/bulk JWT bulkUpdateWaitlistSchema Bulk update waitlist entries

Provider Endpoints

Method Endpoint Auth Validation Description
GET /api/clinic/providers JWT -- List providers
POST /api/clinic/providers JWT createProviderSchema Create provider
PUT /api/clinic/providers/:id JWT -- Update provider
DELETE /api/clinic/providers/:id JWT -- Delete provider
POST /api/clinic/providers/sync JWT -- Sync providers from OSCAR

Route name

The sync endpoint is /api/clinic/providers/sync (not /api/clinic/providers/emr-sync).


Schedule Endpoints

Method Endpoint Auth Validation Description
GET /api/clinic/schedule JWT -- Get business hours
PUT /api/clinic/schedule JWT updateScheduleSchema Update business hours
POST /api/clinic/schedule/sync JWT -- Sync schedule from EMR

EMR Configuration Endpoints

Method Endpoint Auth Validation Description
GET /api/clinic/emr JWT -- Get EMR configuration
PUT /api/clinic/emr JWT -- Update EMR configuration
POST /api/clinic/emr/test JWT -- Test OSCAR connection
POST /api/clinic/emr/test-schedule JWT -- Diagnostic: verify schedule data flow for provider/date
POST /api/clinic/emr/sync JWT -- Sync all data from EMR

OSCAR OAuth Endpoints

3-legged OAuth 1.0a flow for OSCAR EMR credential exchange. These routes are unauthenticated (browser redirect flow).

Method Endpoint Auth Description
POST /api/oscar-oauth/initiate None Start OAuth flow. Body: {clinicId}. Returns {authorizationUrl, state}
GET /api/oscar-oauth/callback None Receive OAuth verifier after OSCAR authorization. Exchanges for access token

Flow:

  1. Admin clicks "Connect OSCAR" → POST /api/oscar-oauth/initiate with clinicId
  2. Server fetches request token from OSCAR, returns authorizationUrl
  3. Admin is redirected to OSCAR login page to authorize
  4. OSCAR redirects back to /api/oscar-oauth/callback with oauth_verifier
  5. Server exchanges verifier for access token, encrypts (AES-256-GCM), stores in ClinicConfig
  6. Token TTL set to 10 years (permanent OAuth keys). oscarOauthTokenExpiresAt tracked for monitoring
  7. Adapter cache invalidated after successful token exchange

State management: In-memory pendingRequests Map with 10-minute expiry and CSRF state validation.

Route names

  • EMR config: /api/clinic/emr (not /api/clinic/emr/config)
  • Test connection: /api/clinic/emr/test (not /api/clinic/emr/test-connection)

POST /api/clinic/emr/test-schedule

Diagnostic endpoint to verify schedule data flow for a specific provider and date.

Auth: JWT

Request:

{
  "providerId": "100001",
  "date": "2026-02-17"
}


Vapi Management (Clinic-level)

Method Endpoint Auth Description
GET /api/clinic/vapi/calls JWT Get Vapi calls (falls back to demo data)
GET /api/clinic/vapi/calls/:id JWT Get specific Vapi call details
GET /api/clinic/vapi/analytics JWT Get Vapi call analytics
GET /api/clinic/vapi/assistants JWT Get Vapi assistants list

Voice Agent Control

Method Endpoint Auth Description
GET /api/clinic/voice-agent JWT + requireClinicAccess Get voice agent enabled status
POST /api/clinic/voice-agent JWT + requireClinicAccess Toggle voice agent on/off

Clinical Settings

Method Endpoint Auth Validation Description
GET /api/clinic/clinical-settings JWT + requireClinicAccess -- Get clinical control settings
PUT /api/clinic/clinical-settings JWT + requireClinicAccess updateClinicalSettingsSchema Update greeting, pharmacy, appointment types

Privacy & Compliance

Method Endpoint Auth Validation Description
GET /api/clinic/privacy-officer JWT -- Get privacy officer info
PUT /api/clinic/privacy-officer JWT updatePrivacyOfficerSchema Set privacy officer
GET /api/clinic/baa-status JWT -- Get BAA/DPA status
PUT /api/clinic/baa-status JWT updateBAAStatusSchema Update BAA status
GET /api/clinic/data-retention JWT -- Get data retention settings
PUT /api/clinic/data-retention JWT updateRetentionSchema Update retention settings

OSCAR Config Endpoints

Method Endpoint Auth Validation Description
GET /api/clinic/oscar-config JWT + requireClinicAccess -- Get OSCAR config (codes, types, sync status)
POST /api/clinic/oscar-config/pull JWT + requireClinicAccess -- Pull all config from OSCAR instance
POST /api/clinic/oscar-config/pull/:domain JWT + requireClinicAccess -- Pull specific config domain
PUT /api/clinic/oscar-config/booking-constraints JWT + requireClinicAccess updateBookingConstraintsSchema Update booking constraints
PUT /api/clinic/oscar-config/template-codes JWT + requireClinicAccess updateTemplateCodeOverridesSchema Update bookable/blocked schedule code overrides
PUT /api/clinic/oscar-config/appointment-types JWT + requireClinicAccess updateAppointmentTypeMappingsSchema Update appointment type mappings

Pull domains: template-codes, appointment-types, appointment-statuses, providers


Onboarding Endpoints

Method Endpoint Auth Description
GET /api/clinic/onboarding JWT + requireClinicAccess Get onboarding progress
PUT /api/clinic/onboarding JWT + requireClinicAccess Update onboarding step (validated with updateOnboardingSchema)
GET /api/clinic/onboarding/validate JWT + requireClinicAccess Pre-launch checklist validation
POST /api/clinic/onboarding/go-live JWT + requireClinicAccess Activate clinic
POST /api/clinic/onboarding/complete JWT + requireClinicAccess Complete onboarding, notify admins
POST /api/clinic/onboarding/test-slots JWT + requireClinicAccess Test schedule slot retrieval
POST /api/clinic/onboarding/test-patient-search JWT + requireClinicAccess Test patient search

Route name

The update endpoint is PUT /api/clinic/onboarding (not PUT /api/clinic/onboarding/step/:step).


Support Tickets (Clinic)

Method Endpoint Auth Validation Description
POST /api/clinic/support/tickets JWT + requireClinicAccess createTicketSchema Create support ticket
GET /api/clinic/support/tickets JWT + requireClinicAccess -- List own tickets
GET /api/clinic/support/tickets/:id JWT + requireClinicAccess -- Ticket detail
POST /api/clinic/support/tickets/:id/messages JWT + requireClinicAccess createTicketMessageSchema Reply to ticket

Admin Endpoints (Vitara Admin Only)

All admin endpoints require JWT + requireRole('vitara_admin').

Clinic Management

Method Endpoint Description
GET /api/admin/clinics List clinics (paginated)
POST /api/admin/clinics Create clinic + user + config (validated with createClinicSchema)
GET /api/admin/clinics/overview Get clinic overviews for admin dashboard
GET /api/admin/clinics/:clinicId Get clinic detail
GET /api/admin/clinics/:clinicId/stats Get stats for a specific clinic
GET /api/admin/clinics/:clinicId/calls Get recent calls for a specific clinic
GET /api/admin/stats System-wide statistics
GET /api/admin/health System health
GET /api/admin/health/detailed Detailed health with latency

Removed endpoints

The following previously documented endpoints do not exist in code:

  • ~~PUT /api/admin/clinics/:id~~ (no update route for admin clinics)
  • ~~DELETE /api/admin/clinics/:id~~ (no delete route)
  • ~~GET /api/admin/clinics/stats~~ (use GET /api/admin/stats or GET /api/admin/clinics/:clinicId/stats)
  • ~~PUT /api/admin/clinics/:id/emr-config~~ (EMR config managed via /api/clinic/emr routes)
  • ~~POST /api/admin/clinics/:id/go-live~~ (use PUT /api/admin/clinics/:clinicId/activate)

Debug Mode

Method Endpoint Description
GET /api/admin/debug Get VITARA_DEBUG mode status
POST /api/admin/debug Toggle VITARA_DEBUG mode (enables PHI in logs)

Provisioning

Method Endpoint Description
GET /api/admin/clinics/pending-activation List clinics awaiting activation
GET /api/admin/clinics/:clinicId/provisioning Get provisioning status for a clinic
PUT /api/admin/clinics/:clinicId/activate Activate clinic (requires phone assigned)

Vapi Management (Admin)

Method Endpoint Description
GET /api/admin/vapi/mappings List all clinic-assistant mappings
GET /api/admin/vapi/phone-numbers List all Vapi phone numbers
PUT /api/admin/clinics/:clinicId/vapi Update Vapi config for a clinic
POST /api/admin/clinics/:clinicId/vapi/test Test Vapi connection
DELETE /api/admin/clinics/:clinicId/vapi Remove Vapi config from clinic

Audit & Data Retention

Method Endpoint Description
GET /api/admin/audit Audit logs (paginated, filtered)
POST /api/admin/data-retention/run Manual data retention trigger

Route name

The audit endpoint is /api/admin/audit (not /api/admin/audit-logs). There is no /api/admin/audit-logs/summary endpoint.


Support Tickets (Admin)

Method Endpoint Validation Description
GET /api/admin/support/tickets -- All tickets (system-wide)
GET /api/admin/support/tickets/:id -- Ticket detail (with internal notes)
PUT /api/admin/support/tickets/:id updateTicketSchema Update status/priority/assignee
POST /api/admin/support/tickets/:id/messages createTicketMessageSchema Reply (supports isInternal)

Fax Intelligence (v4.1.1)

All fax endpoints require JWT authentication and clinic association (req.user.clinicId).

Pipeline Architecture

══════════════════════════════════════════════════════════════════════════
 INGESTION — TWO ENTRY POINTS
══════════════════════════════════════════════════════════════════════════

 PATH A: Manual Upload                    PATH B: Automatic Polling
 ─────────────────────                    ────────────────────────

 Browser (React UI)                       FaxPollerService (setInterval)
   │                                        │
   │ POST /api/fax/upload                   │ poll() every FAX_POLL_INTERVAL_MS
   │ multipart/form-data                    │ (default: 10s)
   │ field: "fax", max: 10MB               │ guard: skip if already polling
   │ JWT Bearer auth                        │
   ▼                                        ▼
 ┌────────────────────────┐              ┌──────────────────────────────────┐
 │ Multer (faxUpload)     │              │ IFaxProvider.getInbox({          │
 │ disk → uploads/fax/    │              │   viewedStatus: 'UNREAD'        │
 │ {uuid}.{ext}           │              │ })                              │
 │                        │              │                                  │
 │ Accepts:               │              │ Returns: FaxMetadata[]           │
 │  PDF, PNG, JPG,        │              │  { faxId, fileName, receivedAt, │
 │  JPEG, TIFF            │              │    callerNumber, remoteId,      │
 └───────────┬────────────┘              │    pages, viewedStatus }        │
             │                           └──────────────┬───────────────────┘
             │                                          │
             │                           Filter out seenFaxIds (in-memory Set)
             │                                          │
             │                                          ▼
             │                           ┌──────────────────────────────────┐
             │                           │ For each new fax:                │
             │                           │  1. provider.retrieveFax(faxId)  │
             │                           │     → { base64Pdf, pages }       │
             │                           │  2. Write to uploads/fax/        │
             │                           │     {uuid}.pdf                   │
             │                           │  3. provider.markAsViewed(faxId) │
             │                           └──────────────┬───────────────────┘
             │                                          │
             ▼                                          ▼
═══════════════════════════════════════════════════════════════════════
 BOTH PATHS → faxService.processFaxDocument(clinicId, filePath, source)
═══════════════════════════════════════════════════════════════════════
 ┌──────────────────────────────┼──────────────────────────────────────┐
 │  STEP 0: DB Record           │  prisma.faxDocument.create           │
 │  status = 'processing'       │  { clinicId, storagePath, source }   │
 ├──────────────────────────────┼──────────────────────────────────────┤
 │  STEP 1: AI Extraction       │  processDocument(filePath)           │
 │  (fax-extraction.service.ts) │                                      │
 │                              │  .pdf  → pdftoppm -png -r 200        │
 │                              │  .tiff → sharp() multi-page → PNG    │
 │                              │  .png/.jpg → use directly            │
 │                              │                                      │
 │  Provider Router:            │  FAX_AI_PROVIDER env var selects:    │
 │    anthropic → Claude        │  (model via FAX_ANTHROPIC_MODEL)     │
 │    openai   → GPT-4o-mini   │                                      │
 │    google   → Gemini 2.0    │                                      │
 │                              │                                      │
 │  → Up to 5 images (base64 + dynamic MIME) + extraction prompt       │
 │  → Returns structured JSON: documentType, urgency, patient fields   │
 │    (firstName, lastName, phn, dateOfBirth — each with confidence),   │
 │    sender, recipient, summary, flags[]                               │
 │                              │                                      │
 │  DB update: status='extracted', aiModel, aiConfidence, extractedData│
 ├──────────────────────────────┼──────────────────────────────────────┤
 │  STEP 2: Patient Matching    │  matchPatient(extraction, clinicId)  │
 │  (fax-matching.service.ts)   │                                      │
 │                              │  Cascade:                            │
 │                              │  1. PHN search (conf ≥ 0.7) → 1.0   │
 │                              │  2. Name+DOB (conf ≥ 0.5)  → 0.95   │
 │                              │  3. Name-only              → 0.65   │
 │                              │  4. No match               → review │
 │                              │                                      │
 │  matched = confidence ≥ 0.85 │                                      │
 │  Uses EmrAdapterFactory      │  (SOAP or Bridge adapter per clinic) │
 │                              │                                      │
 │  DB update: status='matched' or 'review_needed'                     │
 │    matchedDemographicId, matchConfidence, matchMethod                │
 └──────────────────────────────┴──────────────────────────────────────┘

 DB STATUS FLOW:
 processing → extracted → matched ────────→ verified  (MOA confirms)
                        → review_needed ──→ verified  (MOA corrects)
 processing → error

Fax Provider Abstraction (IFaxProvider)

The fax ingestion source is pluggable via the IFaxProvider interface:

interface IFaxProvider {
  providerType: string;
  getInbox(opts?: { viewedStatus: 'UNREAD' | 'ALL' }): Promise<FaxMetadata[]>;
  retrieveFax(faxId: string): Promise<FaxContent>;
  markAsViewed(faxId: string): Promise<void>;
  seedFax?(pdfPath: string, metadata: Partial<FaxMetadata>): FaxMetadata;  // demo
  resetInbox?(): void;                                                       // demo
}
Provider FAX_PROVIDER value Status Description
MockSRFaxProvider mock-srfax Implemented In-memory inbox from fixtures/synthetic-faxes/. Supports seed/reset for demos
SRFax srfax Stubbed Env value accepted, no adapter class yet
None none Stubbed Disables fax polling

To add a new fax provider (e.g., RingCentral), implement IFaxProvider and register in fax.service.ts getProvider() factory.

Document Management

Method Endpoint Auth Description
POST /api/fax/upload JWT + clinic Upload fax PDF/image, triggers AI extraction + patient matching pipeline
GET /api/fax/documents JWT + clinic List fax documents (paginated). Query: ?page=1&limit=20&status=matched. Response shape: data: { docs: FaxDocument[], pagination: { page, limit, total, pages } } (not a flat array)
GET /api/fax/documents/:id JWT + clinic Get single fax document with full extraction and match details
PUT /api/fax/documents/:id/verify JWT + clinic Confirm or correct patient match (PUT, not POST). Body: { correctedDemographicId?: string }

Polling (Mock SRFax Provider)

Method Endpoint Auth Description
POST /api/fax/polling/start JWT + clinic Start automated inbox polling (interval from FAX_POLL_INTERVAL_MS)
POST /api/fax/polling/stop JWT + clinic Stop polling
GET /api/fax/polling/status JWT + clinic Get poller status: { status, processedCount, pendingCount, errorCount, lastPollAt }

Demo Controls

Method Endpoint Auth Description
POST /api/fax/seed JWT + clinic Seed a fixture fax into mock inbox. Body: { fixture: "fax-001-referral" }
POST /api/fax/reset JWT + clinic Reset mock inbox to initial fixture state
GET /api/fax/fixtures JWT + clinic List available fixture names for seeding

POST /api/fax/upload

Upload a fax document for AI processing. Accepts multipart form data.

Request: Content-Type: multipart/form-data

Field Type Required Description
fax File Yes PDF, PNG, JPG, JPEG, or TIFF file (max 10MB)

Response (200):

{
  "success": true,
  "data": {
    "id": "e1eda772-f9cc-453f-967d-fb2abfc931d1",
    "status": "matched",
    "documentType": "referral_letter",
    "urgency": "routine",
    "extractedData": {
      "patient": {
        "firstName": { "value": "John", "confidence": 0.95 },
        "lastName": { "value": "Smith", "confidence": 0.98 },
        "phn": { "value": "9876543210", "confidence": 0.90 },
        "dateOfBirth": { "value": "1985-03-15", "confidence": 0.85 }
      },
      "sender": { "name": "Dr. Patel", "clinicName": "Pacific Cardiology", "faxNumber": "604-555-0101" },
      "recipient": { "doctorName": "Dr. Chen", "clinicName": "Richmond Family Practice" },
      "summary": "Cardiology consultation requested. ECG shows ST changes.",
      "flags": ["follow-up needed"]
    },
    "aiModel": "claude-sonnet-4-6",
    "aiConfidence": 0.98,
    "matchedDemographicId": "42",
    "matchedPatientName": "John Smith",
    "matchConfidence": 1.0,
    "matchMethod": "phn_exact",
    "pageCount": 2,
    "processingTimeMs": 5200
  }
}


Notifications

Method Endpoint Auth Description
GET /api/notifications JWT Current user's notifications
PUT /api/notifications/read-all JWT Mark all as read
PUT /api/notifications/:id/read JWT Mark single as read

OSCAR Proxy Endpoints

These endpoints proxy requests to the OSCAR REST Bridge for the admin dashboard. All require JWT authentication.

Proxy pattern

These are mounted under /api/oscar/ via a sub-router. The OSCAR Bridge handles EMR-level security. The exception is GET /api/oscar/health which is public (see Health section).

Method Endpoint Description
GET /api/oscar/patients/search Search patients (?term=, ?phone=, ?name=&firstName=)
GET /api/oscar/patients/:id Get patient by ID
POST /api/oscar/patients Register new patient
GET /api/oscar/providers Get all providers
GET /api/oscar/providers/:id Get provider by ID
GET /api/oscar/providers/:id/availability Get provider availability (?date=YYYY-MM-DD required)
GET /api/oscar/appointments Get appointments (?providerId=&startDate=&endDate= required)
POST /api/oscar/appointments Book appointment
PUT /api/oscar/appointments/:id Update appointment
DELETE /api/oscar/appointments/:id Cancel appointment
GET /api/oscar/patients/:id/allergies Get patient allergies
GET /api/oscar/patients/:id/prescriptions Get patient prescriptions

GET /api/oscar/patients/search

Query Parameters:

Parameter Type Description
term string General search term
phone string Search by phone number
name string Search by last name
firstName string Used with name for more specific search
limit number Max results (default: 20)

At least one of term, phone, or name is required.


Error Responses

Standard Error Format

All admin/clinic endpoints return:

{
  "success": false,
  "message": "Human-readable error description"
}

Vapi Webhook Error Format

Tool call errors are returned within the results array:

{
  "results": [
    {
      "toolCallId": "tc_abc123",
      "result": {
        "error": true,
        "message": "An internal error occurred processing this request"
      }
    }
  ]
}

Clinic resolution failure returns HTTP 400:

{
  "error": true,
  "message": "Cannot determine clinic for this call. Ensure a Vapi phone number is mapped to a clinic."
}


Rate Limits

Endpoint Limit Burst
Authentication (/api/auth/*) 5/min -
General API 100/min 20
Webhooks (/api/vapi/*) 300/min -
Health check Unlimited -

v3.0 Server-Side Changes Summary

Parameter Naming (Dual Support)

v3.0 Vapi tools use demographicId, appointmentId, providerId, startTime, etc. The server accepts both v3.0 and legacy parameter names for backward compatibility:

v3.0 Name Legacy Names Also Accepted
demographicId patientId
appointmentId appointmentNo
providerId providerNo
appointmentTime (ISO 8601) startTime
newStartTime (ISO 8601) datetime
newProviderId providerId
healthCardType -- (new in v3.0)
language -- (new in v3.0)
callOutcome action, outcome

demographicNo is NOT accepted

Despite legacy docs, demographicNo is not one of the accepted parameter names in the current code. Use patientId or demographicId.

Call Metadata Cache

The callMetadataCache is an in-memory Map<string, CallMetadata> that bridges tool-call webhooks and end-of-call-report webhooks:

Function Purpose
setCallMetadata(callId, data) Store metadata during tool calls
getCallMetadata(callId) Retrieve in saveCallLog for merging into DB

Set by: log_call_metadata, create_appointment, register_new_patient Read by: saveCallLog (on end-of-call-report webhook)

Appointment Type Validation

Server validates appointmentType against DB-driven config or default list ['B', '2', '3', 'P']. Invalid values silently default to 'B'.

Vapi Response Timeout

Vapi has a 5-second timeout for tool-call responses. The server logs a warning for any response exceeding 5 seconds.