Server API & Data Flows¶
Complete reference for the VitaraVox Admin API server — Express 4 + TypeScript running on port 3002, managed by PM2 as vitara-admin-api.
Version: v4.3.0 | Last verified: 2026-03-09
Server Stack¶
| Component | Technology | Purpose |
|---|---|---|
| Runtime | Node.js + TypeScript | Server language |
| Framework | Express 4 | HTTP routing |
| ORM | Prisma 5.22 | Database access |
| Database | PostgreSQL 16 | Persistent storage |
| Validation | Zod | Request/response schemas |
| Logging | Pino + pino-http | Structured JSON logging (5 layers) |
| Resilience | opossum | Circuit breakers (4s timeout, 50% threshold) |
| Process Manager | PM2 | vitara-admin-api via npx tsx src/index.ts |
| Auth | JWT (stateless) | Bearer token authentication — no DB sessions |
| SMS | Telnyx | Platform-level SMS via per-clinic sender numbers |
1. Request Lifecycle¶
Every HTTP request passes through the middleware stack in exact mount order from index.ts:33-79.
┌──────────────────────────────────────────────────────────────────────┐
│ INCOMING HTTP REQUEST │
└─────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ 1. trust proxy index:36 │
│ Express trusts X-Forwarded-For (behind nginx/Caddy) │
├──────────────────────────────────────────────────────────────────────┤
│ 2. Helmet index:39 │
│ Security headers (X-Frame-Options, CSP, HSTS, etc.) │
├──────────────────────────────────────────────────────────────────────┤
│ 3. Request Logger (pino-http) request-logger:14-32 │
│ Correlation ID: x-request-id header OR randomUUID() │
│ Level: >=500 → error, >=400 → warn, else → info │
│ Redacts: authorization, x-api-key, cookie │
│ Skips: /health endpoint │
├──────────────────────────────────────────────────────────────────────┤
│ 4. CORS + Body Parser index:45-49 │
│ Configurable origins, JSON parsing │
├──────────────────────────────────────────────────────────────────────┤
│ 5. Audit Middleware audit:1-100 │
│ POST/PUT/PATCH/DELETE → AuditLog table │
│ Excludes: /api/vapi/*, /vapi-webhook, /health │
│ Redacts 14 sensitive keys (passwords, tokens, secrets) │
│ Non-blocking (fire-and-forget DB write) │
├──────────────────────────────────────────────────────────────────────┤
│ 6. Rate Limiter (per route group) rate-limit:15-39 │
│ See §2 Route Map for which limiter applies to each group │
├──────────────────────────────────────────────────────────────────────┤
│ 7. Auth (per route) │
│ Webhook routes: HMAC-SHA256 / API Key / Bearer vapi-auth:32-80│
│ Dashboard routes: JWT Bearer → req.user auth:15-24 │
├──────────────────────────────────────────────────────────────────────┤
│ 8. Zod Validation (per route) │
│ Schema validation on params, query, body │
└─────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ ROUTE HANDLER │
│ │
│ Route Handler → Service Layer → EMR Adapter → OSCAR API │
│ │ │
│ ▼ │
│ Prisma (PostgreSQL 16) │
└─────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ HTTP RESPONSE │
│ { status, data?, error?, code? } │
└──────────────────────────────────────────────────────────────────────┘
2. Route Map¶
Routes are mounted in index.ts:55-79. Mount order matters — webhook routes MUST come before /api to avoid path conflicts.
Rate Limiting (3-tier)¶
| Limiter | Window | Max | Applied To | Source |
|---|---|---|---|---|
authLimiter |
60s | 5 req | /api/auth/* |
rate-limit.ts:15-21 |
webhookLimiter |
60s | 300 req | /api/vapi/*, /vapi-webhook |
rate-limit.ts:23-29 |
apiLimiter |
60s | 100 req | /api/*, /api/fax/* |
rate-limit.ts:31-39 |
All limiters use standardHeaders: true, legacyHeaders: false and return 429 Too Many Requests as JSON.
Health Check (no auth)¶
| Method | Path | Handler | Source |
|---|---|---|---|
GET |
/health |
healthService.getHealth() | index.ts:55-63 |
Auth Routes (/api/auth/* — authLimiter)¶
| Method | Path | Auth | Handler |
|---|---|---|---|
POST |
/api/auth/login |
No | authService.login(email, password) → |
POST |
/api/auth/refresh |
No | authService.refreshAccessToken(refreshToken) → |
GET |
/api/auth/me |
JWT | authService.getUserById(userId) |
POST |
/api/auth/logout |
JWT | Returns success (stateless — no DB session invalidation) |
No Registration Endpoint
There is no POST /api/auth/register. Admin accounts are created via the admin provisioning flow.
Webhook Routes (/api/vapi/* — webhookLimiter + vapiWebhookAuth)¶
| Method | Path | Auth | Handler |
|---|---|---|---|
POST |
/api/vapi |
HMAC/Key/Bearer | Main Vapi webhook handler |
POST |
/vapi-webhook |
HMAC/Key/Bearer | Legacy URL (same handler) |
Dashboard API Routes (/api/* — apiLimiter + JWT auth)¶
All routes below require authMiddleware (valid JWT).
Clinic Manager Routes (own clinic only)¶
| Method | Path | Extra Auth | Purpose |
|---|---|---|---|
GET |
/api/clinic/me |
— | Get current user's clinic |
PUT |
/api/clinic/me |
Zod | Update clinic details |
GET |
/api/clinic/stats |
— | Call statistics |
GET |
/api/clinic/calls |
— | Call history (paginated) |
GET |
/api/clinic/settings |
— | Clinic settings |
GET |
/api/clinic/analytics |
— | Analytics data |
GET |
/api/clinic/call-history |
— | Call history with filtering |
GET/POST |
/api/clinic/waitlist |
— | Waitlist list/create |
PUT/DELETE |
/api/clinic/waitlist/:id |
— | Waitlist update/delete |
POST |
/api/clinic/waitlist/bulk |
— | Bulk waitlist update |
GET/POST |
/api/clinic/providers |
— | Provider list/create |
PUT/DELETE |
/api/clinic/providers/:id |
— | Provider update/delete |
POST |
/api/clinic/providers/sync |
— | Sync providers from OSCAR |
GET |
/api/clinic/providers/:id/effective-config |
requireClinicAccess | Effective provider config (3-level merge) |
PUT |
/api/clinic/providers/:id/config |
requireClinicAccess + Zod | Update provider voice config |
PUT |
/api/clinic/voice-config |
requireClinicAccess + Zod | Update clinic voice booking config |
GET/PUT |
/api/clinic/schedule |
— | Schedule config |
POST |
/api/clinic/schedule/sync |
— | Sync schedule from OSCAR |
GET/PUT |
/api/clinic/emr |
— | EMR settings |
POST |
/api/clinic/emr/test |
— | Test EMR connection |
POST |
/api/clinic/emr/test-schedule |
— | Test schedule data flow |
POST |
/api/clinic/emr/sync |
— | Full OSCAR sync |
POST/DELETE |
/api/clinic/logo |
— | Logo upload/delete |
GET |
/api/clinic/vapi/calls |
— | Vapi call list |
GET |
/api/clinic/vapi/calls/:id |
— | Single Vapi call |
GET |
/api/clinic/vapi/analytics |
— | Vapi analytics |
GET |
/api/clinic/vapi/assistants |
— | List assistants |
OSCAR Config Routes (requireClinicAccess)¶
| Method | Path | Purpose |
|---|---|---|
POST |
/api/clinic/oscar-config/pull |
Pull all OSCAR config |
POST |
/api/clinic/oscar-config/pull/:domain |
Pull domain-specific config |
GET |
/api/clinic/oscar-config |
Get OSCAR config |
PUT |
/api/clinic/oscar-config/booking-constraints |
Update booking constraints |
PUT |
/api/clinic/oscar-config/template-codes |
Update template code overrides |
PUT |
/api/clinic/oscar-config/appointment-types |
Update appointment type mappings |
SMS Config Routes¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/clinic/sms-config |
Get SMS configuration |
PUT |
/api/clinic/sms-config |
Update SMS settings |
POST |
/api/clinic/sms/test |
Send test SMS |
Compliance Routes¶
| Method | Path | Purpose |
|---|---|---|
GET/PUT |
/api/clinic/privacy-officer |
Privacy officer management |
GET/PUT |
/api/clinic/baa-status |
BAA/DPA status |
GET/PUT |
/api/clinic/data-retention |
Data retention settings |
Onboarding Routes (requireClinicAccess)¶
| Method | Path | Purpose |
|---|---|---|
GET/PUT |
/api/clinic/onboarding |
Onboarding wizard state |
GET |
/api/clinic/onboarding/validate |
Validate checklist |
POST |
/api/clinic/onboarding/go-live |
Mark clinic go-live |
POST |
/api/clinic/onboarding/complete |
Mark onboarding complete |
POST |
/api/clinic/onboarding/test-slots |
Test booking slots |
OSCAR OAuth Routes (no auth — browser redirect flow)¶
| Method | Path | Purpose |
|---|---|---|
POST |
/api/oscar-oauth/initiate |
Start 3-leg OAuth flow |
GET |
/api/oscar-oauth/callback |
Receive OAuth verifier, exchange tokens |
Admin-Only Routes (requireRole('vitara_admin'))¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/clinics |
List all clinics |
GET |
/api/admin/clinics/overview |
Clinic summary overview |
GET |
/api/admin/clinics/pending-activation |
Clinics awaiting activation |
GET |
/api/admin/clinics/:clinicId |
Get specific clinic |
GET |
/api/admin/clinics/:clinicId/stats |
Clinic stats |
GET |
/api/admin/clinics/:clinicId/calls |
Clinic call history |
PUT |
/api/admin/clinics/:clinicId/vapi |
Configure Vapi for clinic |
POST |
/api/admin/clinics/:clinicId/vapi/test |
Test Vapi connection |
DELETE |
/api/admin/clinics/:clinicId/vapi |
Unconfigure Vapi |
GET |
/api/admin/stats |
Platform-wide stats |
GET |
/api/admin/health |
System health (all services) |
GET |
/api/admin/health/detailed |
Detailed health breakdown |
GET/POST |
/api/admin/debug |
Get/set debug mode (VITARA_DEBUG) |
GET |
/api/admin/vapi/mappings |
Vapi phone→clinic mappings |
GET |
/api/admin/vapi/phone-numbers |
Vapi phone numbers |
GET |
/api/admin/audit |
Audit log viewer |
POST |
/api/admin/data-retention/run |
Manually run data retention |
Fax Routes (apiLimiter + JWT auth)¶
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/fax/upload |
JWT | Upload fax for AI processing |
GET |
/api/fax/documents |
JWT | List faxes (paginated, filterable by status) |
GET |
/api/fax/documents/:id |
JWT | Get single fax detail |
PUT |
/api/fax/documents/:id/verify |
JWT | Confirm/correct patient match |
POST |
/api/fax/polling/start |
JWT | Start fax poller |
POST |
/api/fax/polling/stop |
JWT | Stop fax poller |
GET |
/api/fax/polling/status |
None | Poller status + counts |
POST |
/api/fax/seed |
JWT | Seed fixture fax (demo) |
POST |
/api/fax/reset |
JWT | Reset mock inbox (demo) |
GET |
/api/fax/fixtures |
None | List available fixtures (demo) |
Public Route (no auth)¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/oscar/health |
OSCAR connectivity check |
3. Vapi Webhook Pipeline¶
When a Vapi voice agent invokes a tool, the request flows through a dedicated pipeline in vapi-webhook.ts.
┌──────────────────────────────────────────────────────────────────────┐
│ VAPI VOICE AGENT │
│ (Squad: Router → Patient-ID → Booking/Modification/Registration) │
└────────────────────────────┬─────────────────────────────────────────┘
│
│ POST /api/vapi
│ Body: { message: { type, ... } }
▼
┌──────────────────────────────────────────────────────────────────────┐
│ webhookLimiter (300 req/min) rate-limit:23-29 │
├──────────────────────────────────────────────────────────────────────┤
│ vapiWebhookAuth vapi-auth:32-80 │
│ 1. HMAC-SHA256 (x-vapi-signature + timestamp, 5min replay) │
│ 2. API Key fallback (x-api-key header) │
│ 3. Bearer token fallback (Authorization header) │
│ 4. Dev mode: skip if no secret configured │
├──────────────────────────────────────────────────────────────────────┤
│ MESSAGE TYPE DISPATCH vapi-webhook:409+ │
│ │
│ tool-calls ──────────► handleToolCall() (line 410) │
│ assistant-request ───► resolveAssistant() (line 503) │
│ status-update ───────► handleStatusUpdate() (line 510) │
│ end-of-call-report ──► saveCallLog() (line 517) │
│ transfer-destination ► handleTransferDest() (line 544) │
└──────────────────────────────────────────────────────────────────────┘
│
│ tool-calls path (primary flow)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ 1. CLINIC RESOLUTION vapi-webhook:317-343 │
│ vapiPhone cache → Clinic lookup by phone number │
│ Fallback: Vapi API phone number fetch + 1h cache │
│ │
│ 2. voiceAgentEnabled CHECK │
│ If disabled → reject with "Voice agent is not active" │
│ │
│ 3. PARAMETER EXTRACTION │
│ Parse toolCallList[0].function.arguments │
│ Extract call metadata (call.customer.number, assistantId) │
│ │
│ 4. TOOL DISPATCH vapi-webhook:620-1400+ │
│ Route to handler by function name (see §4) │
│ │
│ 5. SMS TRIGGER (if applicable) vapi-webhook:259-305 │
│ create/cancel/update → fireSmsBehindWebhook() │
│ Uses setImmediate() to not block webhook response │
│ │
│ 6. RESPONSE FORMAT │
│ { results: [{ toolCallId, result: "natural language string" }] } │
└──────────────────────────────────────────────────────────────────────┘
Key Design Decisions¶
| Decision | Rationale | Source |
|---|---|---|
Caller phone from call.customer.number |
Telnyx metadata is authoritative; LLM input unreliable | vapi-webhook.ts:755-778 |
| Past-date clamping to today | GPT-4o unreliable with dates; server enforces safety | vapi-webhook.ts |
| Single-slot returns | Concise for voice interactions (caller can't review a list) | vapi-webhook.ts |
| Non-numeric providerId → "any" | Only /^\d+$/ treated as specific provider |
vapi-webhook.ts |
| SMS via setImmediate | Fire-and-forget so webhook responds within Vapi's 5s timeout | vapi-webhook.ts:274 |
4. Tool Call Dispatch¶
14 tools available via the Vapi webhook. Each tool maps to a handler function in vapi-webhook.ts.
| Tool Name | Handler Lines | EMR Method | SMS Trigger | Description |
|---|---|---|---|---|
search_patient |
627-753 | searchPatient(query) | — | Search patient by name |
search_patient_by_phone |
755-778 | searchPatientByPhone(phone) | — | Search by caller's phone (uses call.customer.number) |
get_patient |
780-795 | getPatient(id) | — | Get patient demographics by ID |
get_clinic_info |
797+ | — (DB lookup) | — | Return clinic info, hours, providers |
get_providers |
818-846 | getProviders() | — | List clinic providers |
find_earliest_appointment |
850-883 | findEarliestSlot() | — | Find next available slot |
check_appointments |
887-1011 | getAppointments() | — | Check existing appointments |
create_appointment |
887-1011 | bookAppointment() | Yes | Book appointment (advisory lock + collision check) |
cancel_appointment |
1013-1048 | cancelAppointment() | Yes | Cancel by appointment ID |
update_appointment |
1050-1170 | book new + cancel old | Yes | Reschedule (book-then-cancel pattern) |
register_new_patient |
1174-1272 | registerPatient() | — | Register new patient in OSCAR |
transfer_call |
1276-1287 | — | — | Transfer to clinic phone |
log_call_metadata |
1290+ | — (cache write) | — | Write to callMetadataCache |
SMS Trigger Flow¶
When create_appointment, cancel_appointment, or update_appointment succeeds and the caller provided smsConsent: true:
Tool handler completes
│
▼
fireSmsBehindWebhook() vapi-webhook:259-305
│
│ setImmediate() (line 274)
│ (non-blocking — webhook response already sent)
▼
sms.service.ts guard chain (6 checks) sms.service:174-218
│
▼
Telnyx POST /v2/messages sms.service:243-250
│
▼
CallLog.smsSentAt = now() (DB update)
5. In-Memory Caches¶
5 in-memory caches operate in the server process. None are shared across PM2 instances.
| Cache | Type | TTL | Cleanup | Source |
|---|---|---|---|---|
adapterCache |
Map<string, {adapter, created}> |
5 min | 10 min interval (.unref()) | EmrAdapterFactory.ts:24-33 |
callMetadataCache |
Map<string, {data, ts}> |
30 min | Lazy 1% sweep per access | vapi-webhook.ts:228-250 |
vapiPhoneCache |
Map<string, string> |
1 hour | Weekly refresh from Vapi API | vapi-webhook.ts:60-99 |
templateCodes |
Map<string, code[]> |
Per adapter instance | Never (lives with adapter) | OscarSoapAdapter.ts:318 |
seenFaxIds |
Set<string> |
Unbounded | Never cleaned | fax-poller.service.ts:25 |
pendingRequests (OAuth) |
Map<string, request> |
10 min | 5 min interval | oscar-oauth.ts |
seenFaxIds Memory Leak
seenFaxIds grows without bound. Acceptable for demo/pilot but needs TTL or periodic clearing before production scale.
6. Logging Architecture¶
5 layers of logging, each with a distinct purpose.
┌─────────────────────────────────────────────────────────────────┐
│ LOGGING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: REQUEST LOGGER (pino-http) request-logger:14-32 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Every HTTP req/res logged with: │ │
│ │ - Correlation ID (x-request-id or randomUUID) │ │
│ │ - Method, URL, status, response time │ │
│ │ - Redacted: authorization, x-api-key, cookie │ │
│ │ - Level: 5xx→error, 4xx→warn, else→info │ │
│ │ - Skips: /health │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Layer 2: STRUCTURED LOGGER (Pino) logger:13-64 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Production: JSON to stdout (for log aggregation) │ │
│ │ Development: pino-pretty (color, timestamps) │ │
│ │ Level: LOG_LEVEL env || prod ? 'info' : 'debug' │ │
│ │ Dynamic: setLogLevel() changes at runtime │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Layer 3: DEBUG LOGGER (PHI-aware) logger:48-62 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ debugLog(msg, data) — only when VITARA_DEBUG active │ │
│ │ Level: trace | Prefix: [PHI-DEBUG] │ │
│ │ Contains: patient names, DOB, phones, health cards │ │
│ │ Auto-expires after 4 hours │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Layer 4: PHI REDACTION (webhook) vapi-webhook:195-220 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ redactPhi() strips 15 field types from webhook logs: │ │
│ │ patient names, phone, DOB, HIN, address, etc. │ │
│ │ Applied to all Vapi webhook log entries │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Layer 5: AUDIT LOG (DB-persisted) audit:1-100 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ All POST/PUT/PATCH/DELETE → AuditLog table │ │
│ │ Captures: userId, action, resource, IP, userAgent │ │
│ │ Redacts: 14 sensitive keys (passwords, tokens, secrets) │ │
│ │ Immutable: no UPDATE/DELETE at application layer │ │
│ │ Retention: 7 years (PIPEDA 4.1.4) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ QUERY LOGGING (conditional) prisma:19-27 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Prisma query logging enabled when debugManager active │ │
│ │ Shows raw SQL queries in dev/debug mode │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Debug Mode (VITARA_DEBUG)¶
| Aspect | Detail | Source |
|---|---|---|
| Activation (env) | VITARA_DEBUG=true in .env (requires restart) |
debug-manager:51 |
| Activation (API) | POST /api/admin/debug {enabled: true} (no restart) |
debug-manager:61 |
| TTL | 4 hours auto-expiry (prevents accidental production PHI exposure) | debug-manager:17 |
| What it enables | Verbose logging including PHI (names, DOB, phones, health cards) | logger:48-62 |
| What it does NOT disable | HMAC, CORS, rate limiting — only enables verbose logging | — |
| Timer | setTimeout with .unref() (doesn't block graceful shutdown) |
debug-manager:117-121 |
7. Circuit Breaker¶
The server uses opossum circuit breakers to protect against slow or failing OSCAR connections. Configuration from OscarSoapAdapter.ts:478-496.
success
┌───────────────┐
│ │
▼ │
┌───────────┐ │
────────>│ CLOSED │────────┘
│ │
│ Normal │
│ operation │
└─────┬─────┘
│
│ 50% failure threshold
│ exceeded
▼
┌───────────┐
│ OPEN │
│ │
│ All calls │──────────────────┐
│ fail-fast │ │
└─────┬─────┘ │
│ │
│ 30s cooldown │ call attempt
│ timer expires │ (while open)
▼ │
┌───────────┐ │
│ HALF_OPEN │ │
│ │ │
│ Next call │ │
│ is a test │ │
└─────┬─────┘ │
│ │
┌────┴────┐ │
│ │ │
success failure │
│ │ │
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌──────────────┐
│CLOSED │ │ OPEN │ │ Immediate │
│(reset)│ │(reset │ │ rejection │
│ │ │ timer)│ │ (no network) │
└───────┘ └───────┘ └──────────────┘
Configuration:
| Parameter | Value | Source |
|---|---|---|
| Timeout | 4 seconds | OscarSoapAdapter.ts:479 |
| Failure threshold | 50% | OscarSoapAdapter.ts:480 |
| Reset timeout | 30 seconds | OscarSoapAdapter.ts:481 |
| Rolling count buckets | 10 | OscarSoapAdapter.ts:482 |
| Rolling count timeout | 10s | OscarSoapAdapter.ts:483 |
Events logged:
| Event | Level | Source |
|---|---|---|
| Circuit opens | warn | OscarSoapAdapter.ts:485-487 |
| Circuit half-open | info | OscarSoapAdapter.ts:488-490 |
| Circuit closes | info | OscarSoapAdapter.ts:491-493 |
OAuth Expiry Pre-check
Before making OSCAR calls, the adapter checks oscarOauthTokenExpiresAt. If the token is expired, the call is rejected immediately without hitting the circuit breaker (OscarSoapAdapter.ts:526-529).
8. Scheduled Jobs¶
Two cron jobs run via node-cron in jobs/scheduler.ts.
| Job | Cron | Schedule | Handler | Source |
|---|---|---|---|---|
| Data Retention | 0 3 * * * |
Daily 3:00 AM UTC | runDataRetention() | scheduler:16-22 |
| OAuth Token Monitor | 0 6,18 * * * |
6 AM + 6 PM UTC | checkOAuthTokenExpiry() | scheduler:25-31 |
Data Retention (data-retention.ts)¶
Per-clinic configurable retention policy:
| Data Type | Default | Action | Config Field |
|---|---|---|---|
| Call transcripts | 90 days | NULL transcript column (preserve row + metadata) |
transcriptRetentionDays |
| Call log rows | 365 days | DELETE entire row | callLogRetentionDays |
| Audit logs | 7 years | No automatic deletion (compliance) | N/A |
Returns: { transcriptsNulled, callLogsDeleted, clinicsProcessed }.
OAuth Token Monitor (scheduler.ts:25-31)¶
- Warning threshold: 48 hours before expiry
- Finds all clinics with
oscarOauthTokenExpiresAt < (now + 48h) - If
hoursLeft <= 0: ERROR "EXPIRED" — patient operations will fail - If
hoursLeft > 0: WARN "EXPIRING SOON" — re-run OAuth flow needed - Logs: clinicId, clinicName, expiresAt, hoursLeft
9. Health Check¶
GET /health runs 3 parallel service checks (from health.service.ts:31-119).
GET /health
│
├──────────────┬──────────────────┬─────────────────────┐
│ │ │ │
▼ ▼ ▼ │
┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ Database │ │ OSCAR Bridge │ │ Vapi API │ │
│ │ │ │ │ │ │
│ SELECT 1 │ │ GET /health │ │ GET │ 5s timeout│
│ via │ │ + X-API-Key │ │ /assistant │ each │
│ Prisma │ │ │ │ + Bearer │ │
└────┬─────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
▼ ▼ ▼ │
latencyMs latencyMs latencyMs │
│ │ │ │
└───────────────┴────────────────┘ │
│ │
▼ │
┌──────────────┐ │
│ Status Logic │ │
│ │ │
│ all healthy │──► 'healthy' │
│ db/oscar down│──► 'down' │
│ otherwise │──► 'degraded' │
└──────────────┘ │
Success conditions:
| Service | Healthy If | Source |
|---|---|---|
| Database | SELECT 1 succeeds |
health.service.ts:50-59 |
| OSCAR Bridge | HTTP status < 500 (includes 401, 404) | health.service.ts:61-84 |
| Vapi API | HTTP status OK or 401 (reachable) | health.service.ts:86-115 |
10. Ops Service¶
Administrative operations via ops.service.ts:152-529. Available to vitara_admin role.
| Method | Timeout | Purpose | Source |
|---|---|---|---|
getSystemStatus() |
— | PM2, disk, memory, OS, node version, uptime | ops:158-178 |
deploy() |
60s build + 15s PM2 | TypeScript build (tsc) + PM2 graceful restart |
ops:183-234 |
vapiGitOpsPush() |
45s | Push Vapi GitOps config (npm run push:dev) |
ops:239-257 |
pm2Action(action) |
15s | restart OR reload vitara-admin-api |
ops:262-270 |
warmSoapClients() |
8s/clinic | Warm SOAP WSDL for all soap-type clinics | ops:275-335 |
getLogs(lines) |
15s | Tail PM2 logs (10-500 lines, default 100) | ops:340-347 |
getBackups() |
— | List backup files (sorted newest first) | ops:352-374 |
deployDocs() |
60s | MkDocs build + copy to site/ | ops:379-398 |
getEnvConfig() |
sync | Audit env vars (presence only, never values) | ops:403-409 |
testSoap() |
— | Test OSCAR SOAP connectivity | ops:414-447 |
getGitStatus() |
15s | Branch, commit hash, message, date, dirty flag | ops:452-468 |
Deploy Lock
A deployLock flag (ops.service.ts:153) prevents concurrent deploy operations. If a deploy is in progress, subsequent deploy requests are rejected.
Hardcoded paths:
| Constant | Path |
|---|---|
| SERVER_DIR | /home/ubuntu/vitara-platform/admin-dashboard/server |
| PLATFORM_DIR | /home/ubuntu/vitara-platform |
| VAPI_GITOPS_DIR | /home/ubuntu/vitara-platform/vapi-gitops |
| BACKUPS_DIR | /home/ubuntu/vitara-platform/backups |
11. External API Calls¶
Every outbound HTTP call the server makes.
| Service | Method | URL | Auth | Trigger |
|---|---|---|---|---|
| OSCAR (SOAP) | POST | {oscarUrl}/ws/XxxService?wsdl |
WS-Security UsernameToken | EMR adapter calls |
| OSCAR (REST) | GET/POST | {oscarUrl}/ws/services/* |
OAuth 1.0a HMAC-SHA1 | EMR adapter calls (preferRest) |
| OSCAR (OAuth) | GET/POST | {oscarUrl}/oauth/* |
OAuth 1.0a | Token exchange flow |
| Vapi API | GET | https://api.vapi.ai/assistant |
Bearer token | Health check |
| Vapi API | GET | https://api.vapi.ai/phone-number |
Bearer token | Phone cache refresh |
| Telnyx | POST | https://api.telnyx.com/v2/messages |
API key in header | SMS confirmation |
| OSCAR Bridge | GET | {bridgeUrl}/health |
X-API-Key header | Health check |
12. Error Handling¶
Vapi Tool Error Format¶
Tool call errors are returned as natural language strings (the LLM reads them and speaks to the patient):
{
"results": [{
"toolCallId": "abc123",
"result": "I was unable to search for patients at this time. The clinic system is temporarily unavailable."
}]
}
HTTP Error Response Format¶
Dashboard API errors follow a consistent structure:
{
"status": 400,
"error": "Human-readable error message",
"code": "MACHINE_READABLE_CODE",
"details": {}
}
| HTTP Status | Meaning | Common Causes |
|---|---|---|
| 400 | Bad Request | Zod validation failure, missing parameters |
| 401 | Unauthorized | Missing or expired JWT, invalid HMAC signature |
| 403 | Forbidden | Insufficient role, clinic access mismatch |
| 404 | Not Found | Clinic/resource not found |
| 429 | Too Many Requests | Rate limit exceeded (auth: 5/min, API: 100/min, webhook: 300/min) |
| 500 | Internal Server Error | OSCAR adapter failure, database error |
| 503 | Service Unavailable | Circuit breaker OPEN for target clinic |
Circuit Breaker Error¶
When the circuit is OPEN, the adapter returns immediately without making a network call. The webhook handler wraps this as a natural language error for the voice agent.
Graceful Shutdown (index.ts:174-188)¶
On SIGTERM/SIGINT:
- Stop accepting new connections
- Drain existing connections for up to 10 seconds
- Log warning if forced exit after timeout
- The 10s drain time is critical for
update_appointmentwhich books-then-cancels (two sequential EMR calls)
Server Startup Sequence¶
index.ts — startup order
│
├─ 1. Express app creation + middleware mount (lines 33-79)
├─ 2. Route registration (lines 55-79)
├─ 3. HTTP server.listen(PORT) (line 82)
├─ 4. DebugManager.initialize() (line 93)
│ └─ Checks VITARA_DEBUG env var
├─ 5. Scheduler.start() (line 145)
│ └─ Registers cron jobs (data retention, OAuth monitor)
├─ 6. EMR Adapter pre-warmup (background) (lines 148-166)
│ └─ For each clinic with oscarUrl:
│ getEmrAdapter(clinicId) — warms SOAP WSDL or REST TLS
│ Non-blocking, errors logged per-clinic (non-fatal)
└─ 7. Startup complete — accepting requests
Authentication Flows¶
Dashboard JWT (Stateless)¶
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Server │ │ DB │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ POST /auth/login │ │
│ {email, password} │ │
│───────────────────>│ │
│ │ Lookup user │
│ │───────────────────>│
│ │ User record │
│ │<───────────────────│
│ │ │
│ │ bcrypt.compare() │
│ │ │
│ { accessToken, │ │
│ refreshToken } │ (both are JWTs, │
│<───────────────────│ no DB session) │
│ │ │
│ GET /api/clinic/* │ │
│ Authorization: │ │
│ Bearer <token> │ │
│───────────────────>│ │
│ │ jwt.verify() │
│ │ → {userId, role, │
│ │ clinicId} │
│ │ │
│ 200 OK + data │ │
│<───────────────────│ │
Stateless JWT
There is no Session table. JWTs are self-contained. POST /auth/logout returns success but does not invalidate the token server-side. Token expiry is the only revocation mechanism.
Vapi Webhook Auth (Multi-method)¶
POST /api/vapi/*
│
▼
┌─────────────────────────────┐
│ 1. HMAC-SHA256 │──► Valid? ──► AUTHENTICATED
│ x-vapi-signature header │
│ Payload: timestamp + │
│ '.' + JSON.stringify │
│ 5-min replay window │
│ crypto.timingSafeEqual │
└────────────┬────────────────┘
│ No match
▼
┌─────────────────────────────┐
│ 2. API Key │──► Match? ──► AUTHENTICATED
│ x-api-key header │
└────────────┬────────────────┘
│ No match
▼
┌─────────────────────────────┐
│ 3. Bearer Token │──► Match? ──► AUTHENTICATED
│ Authorization: Bearer │
└────────────┬────────────────┘
│ No match
▼
401 Unauthorized
Production: REJECTS ALL if no VAPI_WEBHOOK_SECRET configured (500)
Development: SKIPS auth if no secret configured
Onboarding Checks¶
When POST /api/clinic/onboarding/validate is called, the server runs pre-launch validation:
| # | Check | Blocking | Description |
|---|---|---|---|
| 1 | OSCAR connectivity | Yes | Can the server reach the OSCAR instance? |
| 2 | Authentication | Yes | Do SOAP/OAuth credentials work? |
| 3 | Patient search | Yes | Does searchPatient return results? |
| 4 | Provider sync | Yes | Can providers be fetched and stored? |
| 5 | Schedule read | Yes | Can schedule slots be retrieved? |
| 6 | Appointment read | Yes | Can existing appointments be listed? |
| 7 | Appointment write | Yes | Can a test appointment be created? |
| 8 | Appointment cancel | Yes | Can the test appointment be cancelled? |
| 9 | Schedule data flow | No | End-to-end data flow validation (informational) |