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):
- Check
clinicConfig.smsTemplate{Action}{Lang}(e.g.,smsTemplateBookEn) - If non-null, use custom template
- 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 |
Consent UX Flow¶
Source: patient-id-en.md:119-121, booking-en.md:154-162, modification-en.md:122-124
- 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."
- Patient says nothing or agrees →
smsConsent = true(default) - Patient declines → LLM notes
smsDeclined = truein conversation context - Booking/Modification agent passes
smsConsent: falseon tool calls if patient declined - 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).
Related Documentation¶
- Tool Inventory —
smsConsentparameter on create/update/cancel tools - Agent Behaviors — SMS consent UX in Patient-ID, Booking, Modification
- Environment Configuration —
TELNYX_API_KEY,TELNYX_MESSAGING_PROFILE_ID - Database Schema — ClinicConfig SMS fields, CallLog consent tracking
- API Reference — SMS config endpoints