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)¶
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:
Error Response (500):
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:
Clinic Resolution¶
Webhook requests resolve the clinic through:
metadata.clinicId -> call.phoneNumber.number -> call.phoneNumberId (Vapi phone cache) -> clinicService.findClinicByVapiPhone()
- Check
metadata.clinicId(test override) - Check
call.phoneNumber.number(the Vapi number that received the call) - If empty (Telnyx BYO numbers), resolve via
phoneNumberIdfrom a cached Vapi API lookup - 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:
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):
search_patient¶
Search patient by name and date of birth.
Function aliases: search_patient, searchPatient
Arguments:
| 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):
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:
| 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):
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:
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:
| 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):
findAppointmentByPreference¶
Find appointments matching a natural language preference. Returns up to 3 slots.
Function aliases: findAppointmentByPreference
Arguments:
| 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:
| 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):
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:00uses today) - Past-date clamp: If the LLM sends a date in the past, the server clamps it to today before calling OSCAR
appointmentTypevalidated against DB-driven list or defaults['B', '2', '3', 'P']languageandpatientIdcached viasetCallMetadatafor end-of-call log- Uses BookingEngine for slot collision checking
- Advisory lock fail-safe:
acquireAdvisoryLockreturnsfalseon error (nottrue), preventing double-booking under lock degradation - SMS trigger: On success, if
smsConsent !== falseand clinic has SMS enabled, firesfireSmsBehindWebhook()viasetImmediate()(non-blocking)
Response (Success):
{
"success": true,
"appointmentId": "99001",
"message": "Appointment booked for Thursday, February 14th at 9:30 AM",
"smsSent": true
}
Response (Failure):
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:
| 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):
Response (Failure):
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:
add_to_waitlist¶
Add a patient to the new patient waitlist.
Function aliases: add_to_waitlist, addToWaitlist
Arguments:
| 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):
transfer_call¶
Transfer call to clinic staff.
Function aliases: transfer_call, transferToHuman
Arguments:
Response:
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:
- Voice agent asks patient about SMS during the booking flow
- Patient's response is captured as
smsConsentparameter on the tool call - On successful booking/cancel/reschedule, server fires SMS via
setImmediate()(non-blocking) - SMS is sent via platform-level Telnyx API with per-clinic sender numbers
smsSent: trueis included in the tool response if SMS was triggeredCallLog.smsConsentandCallLog.smsSentAttrack 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:
| 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:
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:
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):
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):
POST /api/auth/refresh¶
Request (validated with refreshTokenSchema):
Response:
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:
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:
PUT /api/clinic/me¶
Update current user's clinic details.
Auth: JWT
Validation: updateClinicSchema
Request: Clinic fields to update (per schema).
Response:
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:
- Admin clicks "Connect OSCAR" →
POST /api/oscar-oauth/initiatewith clinicId - Server fetches request token from OSCAR, returns
authorizationUrl - Admin is redirected to OSCAR login page to authorize
- OSCAR redirects back to
/api/oscar-oauth/callbackwithoauth_verifier - Server exchanges verifier for access token, encrypts (AES-256-GCM), stores in ClinicConfig
- Token TTL set to 10 years (permanent OAuth keys).
oscarOauthTokenExpiresAttracked for monitoring - 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:
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~~ (useGET /api/admin/statsorGET /api/admin/clinics/:clinicId/stats) - ~~
PUT /api/admin/clinics/:id/emr-config~~ (EMR config managed via/api/clinic/emrroutes) - ~~
POST /api/admin/clinics/:id/go-live~~ (usePUT /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:
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.