Skip to content

SMS Booking Confirmation

Telnyx-powered SMS for appointment book, reschedule, and cancel confirmations

Last Updated: 2026-03-09 (v4.3.0 — full Telnyx integration, 79 tests)


Overview

VitaraVox sends SMS confirmations to patients after booking, rescheduling, or cancelling appointments. The system uses a platform-level Telnyx account (single API key in env vars) with per-clinic sender numbers stored in the database.

Property Value
Provider Telnyx (POST https://api.telnyx.com/v2/messages)
Consent Model Opt-out (default: send unless patient declines)
Languages English + Mandarin Chinese (6 templates)
Architecture Platform-level API key + per-clinic sender numbers
Delivery Fire-and-forget (setImmediate — never blocks webhook response)
Source sms.service.ts (service), vapi-webhook.ts:259-305 (trigger)

Architecture

┌──────────────────────────────────────────────────────────────────┐
│                        Vapi Voice Call                           │
│  Patient says "yes" to appointment → LLM calls create/update/   │
│  cancel tool with smsConsent param                              │
└──────────────┬───────────────────────────────────────────────────┘
               │ POST /api/vapi (tool-calls message)
┌──────────────────────────────────────────────────────────────────┐
│                    Vapi Webhook Handler                          │
│  vapi-webhook.ts — handleToolCall()                             │
│                                                                  │
│  create_appointment :983  ─┐                                     │
│  cancel_appointment :1024  ├─► fireSmsBehindWebhook()           │
│  update_appointment :1132  ─┘  (vapi-webhook.ts:259-305)        │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ setImmediate() — defers SMS to after webhook response    │   │
│  │ 1. Update CallLog.smsConsent (regardless of send)        │   │
│  │ 2. Call smsService.send()                                │   │
│  └──────────────────────────────────────────────────────────┘   │
└──────────────┬───────────────────────────────────────────────────┘
               │ (webhook response already sent to Vapi)
┌──────────────────────────────────────────────────────────────────┐
│                      SMS Service                                 │
│  sms.service.ts — SmsService.send()                             │
│                                                                  │
│  ┌────────────────────────────────────────────────────────┐     │
│  │ Guard Chain (5 checks — any failure = skip quietly)    │     │
│  │ 1. TELNYX_API_KEY present?                             │     │
│  │ 2. smsConsent !== false?                               │     │
│  │ 3. callerPhone valid (E.164)?                          │     │
│  │ 4. clinicConfig.smsEnabled?                            │     │
│  │ 5. clinicConfig.smsSenderNumber set?                   │     │
│  └───────────────────────┬────────────────────────────────┘     │
│                          ▼                                       │
│  Template selection → Rendering → Telnyx POST                   │
│                                                                  │
│  On success: update CallLog.smsSentAt                           │
└──────────────┬───────────────────────────────────────────────────┘
               │ POST https://api.telnyx.com/v2/messages
┌──────────────────────────────────────────────────────────────────┐
│                     Telnyx API                                   │
│  Bearer auth (platform API key)                                  │
│  messaging_profile_id (optional, from env)                       │
│  from: per-clinic smsSenderNumber                               │
│  to: patient phone (E.164)                                      │
└──────────────────────────────────────────────────────────────────┘

SMS Sequence (Full Path)

Source: vapi-webhook.ts:259-305, sms.service.ts:169-292

  Vapi LLM          Webhook Handler         SMS Service          Telnyx API        DB
     │                    │                      │                    │              │
     │ tool_call:         │                      │                    │              │
     │ create_appointment │                      │                    │              │
     │ {smsConsent:true}  │                      │                    │              │
     │───────────────────►│                      │                    │              │
     │                    │                      │                    │              │
     │                    │ BookingEngine.book()  │                    │              │
     │                    │──────────────────────────────────────────────────────────►│
     │                    │                      │                    │     (INSERT)  │
     │                    │◄─────────────────────────────────────────────────────────│
     │                    │                      │                    │              │
     │                    │ fireSmsBehindWebhook()                    │              │
     │                    │──┐ setImmediate       │                    │              │
     │                    │  │                    │                    │              │
     │◄── tool result ────│  │                    │                    │              │
     │  {smsSent: true}   │  │                    │                    │              │
     │                    │  │                    │                    │              │
     │  (Vapi continues   │  ▼                    │                    │              │
     │   speaking to      │  │ UPDATE CallLog     │                    │              │
     │   patient)         │  │ .smsConsent=true   │                    │              │
     │                    │  │──────────────────────────────────────────────────────►│
     │                    │  │                    │                    │              │
     │                    │  │ smsService.send()  │                    │              │
     │                    │  │───────────────────►│                    │              │
     │                    │  │                    │ Guard 1: API key   │              │
     │                    │  │                    │ Guard 2: consent   │              │
     │                    │  │                    │ Guard 3: phone     │              │
     │                    │  │                    │ Guard 4: smsEnabled│              │
     │                    │  │                    │──────────────────────────────────►│
     │                    │  │                    │ Guard 5: sender#   │   (SELECT)   │
     │                    │  │                    │◄─────────────────────────────────│
     │                    │  │                    │                    │              │
     │                    │  │                    │ Render template    │              │
     │                    │  │                    │                    │              │
     │                    │  │                    │ POST /v2/messages  │              │
     │                    │  │                    │───────────────────►│              │
     │                    │  │                    │   200 OK           │              │
     │                    │  │                    │◄───────────────────│              │
     │                    │  │                    │                    │              │
     │                    │  │                    │ UPDATE CallLog     │              │
     │                    │  │                    │ .smsSentAt = now() │              │
     │                    │  │                    │──────────────────────────────────►│
     │                    │  │                    │                    │              │

Key design choice: setImmediate() at vapi-webhook.ts:274 ensures the webhook returns the tool result to Vapi immediately. The SMS send happens asynchronously — if it fails, the patient still gets their booking confirmation via voice. SMS is never on the critical path.


Guard Chain

The SMS service runs 5 sequential checks before sending. Any failure skips quietly (returns { sent: false, skipReason }) — SMS never throws.

Source: sms.service.ts:169-218

  send(opts) called
  ┌─────────────────────────────────────────┐
  │ Guard 1: TELNYX_API_KEY in env?         │ sms.service.ts:174-177
  │ getTelnyxConfig() returns null → SKIP   │ skipReason: 'no_api_key'
  └───────────┬─────────────────────────────┘
              │ ✓
  ┌─────────────────────────────────────────┐
  │ Guard 2: smsConsent !== false?          │ sms.service.ts:180-184
  │ Patient explicitly declined → SKIP      │ skipReason: 'consent_declined'
  │ null/undefined/true all pass (opt-out)  │
  └───────────┬─────────────────────────────┘
              │ ✓
  ┌─────────────────────────────────────────┐
  │ Guard 3: callerPhone valid?             │ sms.service.ts:186-191
  │ normalizePhone() → E.164 or null        │ skipReason: 'no_phone'
  │ Strips +, extensions, validates length  │ (normalizePhone: lines 74-108)
  └───────────┬─────────────────────────────┘
              │ ✓
  ┌─────────────────────────────────────────┐
  │ Guard 4: clinicConfig.smsEnabled?       │ sms.service.ts:208-212
  │ Per-clinic toggle in DB                 │ skipReason: 'sms_disabled'
  │ (fetches config at lines 194-206)       │
  └───────────┬─────────────────────────────┘
              │ ✓
  ┌─────────────────────────────────────────┐
  │ Guard 5: smsSenderNumber set?           │ sms.service.ts:214-218
  │ Per-clinic phone number for "from"      │ skipReason: 'no_sender_number'
  └───────────┬─────────────────────────────┘
              │ ✓ All guards passed
         Render template → POST to Telnyx

Opt-Out Consent Model

Guard 2 uses an opt-out model: smsConsent defaults to true (send SMS). The patient must explicitly say "I don't want texts" during the call for the LLM to pass smsConsent: false. This matches the Vapi prompt instructions in Patient-ID agents (patient-id-en.md:119-121).


Template System

Default Templates (6)

The service provides 3 actions × 2 languages = 6 default templates, defined in sms.service.ts:53-66:

Action Language Template
book EN Appointment confirmed — {clinicName} + date/time/provider + Reply STOP to opt out
book ZH 预约确认 — {clinicName} + date/time/provider + 回复 STOP 退订
reschedule EN Appointment updated — {clinicName} + old/new date/time + provider
reschedule ZH 预约已更改 — {clinicName} + old/new date/time + provider
cancel EN Appointment cancelled — {clinicName} + date/time/provider + rebook CTA
cancel ZH 预约已取消 — {clinicName} + date/time/provider + rebook CTA

Template Variables

Templates use {varName} placeholders rendered by regex at sms.service.ts:117:

Variable Source Example
{clinicName} Clinic.name "FreshBay Health"
{date} Formatted appointment date "Tuesday, March 15"
{time} Formatted appointment time "2:30 PM"
{providerName} Provider display name "Dr. Chen"
{clinicPhone} Clinic.phone "+1 604-555-0123"
{oldDate} Previous date (reschedule only) "Monday, March 14"
{oldTime} Previous time (reschedule only) "10:00 AM"

Missing/null values render as empty strings (never literal "null" or "undefined").

Custom Templates

Clinics can override any template via the database (ClinicConfig fields). The template resolution order (sms.service.ts:123-146):

  1. Check clinicConfig.smsTemplate{Action}{Lang} (e.g., smsTemplateBookEn)
  2. If non-null, use custom template
  3. Otherwise, fall back to DEFAULT_TEMPLATES[action][language]

Phone Normalization

The normalizePhone() function (sms.service.ts:74-108) converts caller phone numbers to E.164 format:

Input Output Rule
6045551234 +16045551234 10-digit → prepend +1
16045551234 +16045551234 11-digit starting with 1 → prepend +
+16045551234 +16045551234 Already E.164 → keep
(604) 555-1234 +16045551234 Strip non-digit chars
604-555-1234x5678 +16045551234 Strip extension
abc null Contains letters → reject
123 null Too short (< 7 digits) → reject

Configuration

Environment Variables (Platform-Level)

Source: config/env.ts:77-79

Variable Required Description
TELNYX_API_KEY Yes (for SMS) Platform-level Telnyx API key. If empty, SMS is globally disabled.
TELNYX_MESSAGING_PROFILE_ID No Telnyx messaging profile ID. Included in API request if set.

Telnyx API Key ≠ Vapi API Key

The Telnyx API key for SMS is a separate credential from the Vapi API key. Both services use the same Telnyx account, but the keys are different (TELNYX_API_KEY for SMS, telephony managed by Vapi).

Database Fields (Per-Clinic)

Source: schema.prisma:194-203 (ClinicConfig model)

Field Type Default Description
smsEnabled Boolean false Master toggle for this clinic
smsSenderNumber String? null E.164 phone number used as "from"
smsTemplateBookEn String? null Custom booking template (EN)
smsTemplateBookZh String? null Custom booking template (ZH)
smsTemplateCancelEn String? null Custom cancel template (EN)
smsTemplateCancelZh String? null Custom cancel template (ZH)
smsTemplateRescheduleEn String? null Custom reschedule template (EN)
smsTemplateRescheduleZh String? null Custom reschedule template (ZH)
smsInboundWebhookEnabled Boolean false Reserved for future inbound SMS handling

CallLog Fields

Source: schema.prisma:327-328 (CallLog model)

Field Type Description
smsConsent Boolean? Whether patient consented (set by fireSmsBehindWebhook)
smsSentAt DateTime? Timestamp when SMS was delivered to Telnyx (set by SmsService.send)

smsConsent ≠ smsSent

smsConsent records the patient's preference (updated at vapi-webhook.ts:279-283). smsSentAt records actual delivery to Telnyx (updated at sms.service.ts:261-264). A patient can consent but still not receive SMS if the clinic has SMS disabled or the phone is invalid.


API Endpoints

Source: routes/api.ts:2106-2185

GET /api/clinic/config/sms

Returns the clinic's current SMS configuration (per-clinic fields only).

Property Value
Auth JWT + clinic access
Response { success, data: { smsEnabled, smsSenderNumber, smsTemplate*, smsInboundWebhookEnabled } }

POST /api/clinic/config/sms

Updates the clinic's SMS configuration. All fields optional — only provided fields are updated.

Property Value
Auth JWT + clinic access
Validation updateSmsConfigSchema (Zod)
Body Any subset of SMS config fields
Response { success, data: { smsEnabled, smsSenderNumber, smsInboundWebhookEnabled } }

POST /api/clinic/config/sms/validate

Sends a test SMS to verify the clinic's SMS setup.

Property Value
Auth JWT + clinic access
Validation validateSmsSchema (Zod)
Body { testPhone: string }
Response { valid: boolean, error?: string, messageId?: string }

Voice Agent Integration

Tool Parameters

Three Vapi tools include smsConsent as an optional parameter:

Tool YAML Parameter Type Default
create-appointment-65213356.yml smsConsent boolean true (opt-out)
update-appointment-635f59ef.yml smsConsent boolean true (opt-out)
cancel-appointment-f6cef2e7.yml smsConsent boolean true (opt-out)

Tool Response

The smsSent field in tool responses tells the LLM whether to mention the text:

smsSent Value LLM Behavior
true "You'll get a text confirmation shortly."
false / absent Omit any mention of text messages

Source: patient-id-en.md:119-121, booking-en.md:154-162, modification-en.md:122-124

  1. Patient-ID agent (early in call): "We may also send you a text confirmation for any appointments. Let me know if you'd rather not receive texts."
  2. Patient says nothing or agrees → smsConsent = true (default)
  3. Patient declines → LLM notes smsDeclined = true in conversation context
  4. Booking/Modification agent passes smsConsent: false on tool calls if patient declined
  5. Patient can change mind at any point → LLM uses most recent preference

Error Handling

The SMS service never throws — all errors are caught internally and logged:

Error Type Handling Log Level
Guard skip (any of 5) Return { sent: false, skipReason } debug/info
Telnyx 429 (rate limit) Return { sent: false, skipReason: 'telnyx_429' } warn
Telnyx 4xx/5xx Return { sent: false, skipReason: 'telnyx_{status}' } error
Network/DNS failure Return { sent: false, skipReason: 'network_error' } error
CallLog update failure Swallowed (non-critical) debug

PHI safety: The service never logs phone numbers, message bodies, or patient names. Only clinicId, action, language, messageId, and status are logged (sms.service.ts:256).