Twilio WhatsApp Integration
Complete developer guide for the MedSync × Twilio WhatsApp messaging system.
MedSync uses Twilio's WhatsApp Business API to let clinics communicate with patients over WhatsApp. This covers appointment booking (via an AI-powered conversational bot), outbound messages, appointment reminders, bundle (bulk) messaging, and delivery tracking.
Architecture
High-Level Flow
Component Diagram
backend/
main.py ← Inbound webhook: POST /api/webhooks/whatsapp
whatsapp.py ← Twilio signature verification, Gemini intent parsing, reply generation
twilio_client.py ← Twilio REST client (master + subaccount support)
reminders.py ← Background loop: appointment reminder sending
routers/
whatsapp.py ← All WhatsApp REST API endpoints (send, conversations, templates, bundle, provision, etc.)
database/
__init__.py ← whatsapp_senders, whatsapp_conversations, whatsapp_messages tables
frontend/
whatsapp.jsx ← WhatsApp inbox UI (Conversations + Bundle messages tabs)
How Routing Works
MedSync supports multiple clinics (workspaces), each with its own WhatsApp sender number. When Twilio delivers an inbound message, the To field identifies which clinic the message is for:
- Twilio POSTs to
/api/webhooks/whatsappwithFrom(patient) andTo(clinic sender number) - MedSync looks up the sender by the
Tonumber in thewhatsapp_senderstable - The sender's
account_iddetermines which workspace (clinic) owns this conversation - The patient is upserted into that workspace's patient list
Key Concepts
| Term | Description |
|---|---|
| WABA | WhatsApp Business Account — a Meta entity that holds phone numbers. One WABA can have multiple numbers. Created via Twilio Console → Facebook Embedded Signup. |
| Sender | A WhatsApp-registered phone number under a WABA. Each MedSync workspace links to one primary sender. |
| 24-Hour Window | WhatsApp allows freeform replies only within 24 hours of the patient's last inbound message. Outside this window, you must use an approved Meta template message. |
| Template Message | Pre-approved message format (by Meta) required for business-initiated conversations. Has variables like {{1}}, {{2}}. |
| TwiML | Twilio Markup Language — XML returned from the webhook to tell Twilio what reply to send. MedSync uses <Message> TwiML. |
| Workspace | A MedSync clinic account. Each workspace can have up to 3 active WhatsApp senders. |
| Subaccount | A Twilio subaccount created per-clinic for billing isolation. Optional — clinics can share the master Twilio account. |
Setup Guide
1. Environment Variables
Set these on your server (or in .env for local dev):
| Variable | Required | Description |
|---|---|---|
TWILIO_ACCOUNT_SID | Yes | Master Twilio Account SID (starts with AC) |
TWILIO_AUTH_TOKEN | Yes | Master Twilio Auth Token (used for API calls and signature verification) |
TWILIO_WEBHOOK_BASE_URL | Yes (prod) | Public URL for your app, e.g. https://app.gomedsync.com. Used to construct webhook URLs and verify Twilio signatures. Behind a proxy, the internal URL may use http:// which breaks HMAC — this overrides it. |
GEMINI_API_KEY | Yes | Google Gemini API key for the AI-powered WhatsApp bot. |
ALLOWED_ORIGINS | Fallback | If TWILIO_WEBHOOK_BASE_URL is not set, the first origin is used for status callback URLs. |
https:// to http://, the signature will fail. Always set TWILIO_WEBHOOK_BASE_URL to the exact public https URL.
2. Register a WhatsApp Sender on Twilio
This is a manual process — it cannot be automated from MedSync.
- Go to Twilio Console → Messaging → Senders → WhatsApp senders
- Click "Create new sender"
- Choose your number:
- Twilio phone number: select an existing Twilio number from the dropdown
- My own phone number: enter an external number in E.164 format (e.g.
+919495476775). You'll verify it via SMS or phone call.
- Click Continue to proceed to the Facebook Embedded Signup flow
- In the Facebook popup:
- Select your Business portfolio (or create one)
- IMPORTANT: If you already have a WABA connected to your Twilio account, you must select the existing one. Twilio shows a warning with the WABA ID — selecting a different one will cause rejection.
- Add your phone number and verify via SMS/call
- Set a display name (must match your business name per Meta guidelines)
- Complete the flow. The sender will appear as "Online" in the Twilio Console.
3. Configure Webhooks on Twilio
After the sender is registered, configure the webhook URLs:
- Go to Twilio Console → WhatsApp senders → click "Edit Sender" on your number
- Set these fields:
Field Value Webhook URL for incoming messages https://your-domain.com/api/webhooks/whatsappWebhook method HTTP Post Status callback URL https://your-domain.com/api/webhooks/whatsapp/status - Click "Update WhatsApp Sender"
4. Link the Sender to a MedSync Workspace
There are three ways to connect a WhatsApp number to a MedSync workspace:
Option A: Link Existing (recommended)
For numbers already registered on Twilio as WhatsApp senders:
POST /api/whatsapp/link-existing
Authorization: Bearer <token>
X-Account-Id: <workspace_id>
{
"phone_number": "+15559913949",
"display_name": "My Clinic"
}
This saves the number in the whatsapp_senders table for that workspace. Messages from Twilio with To=whatsapp:+15559913949 will route to this workspace.
Option B: Auto-Provision
Creates a Twilio subaccount, buys a US number, and configures webhooks automatically:
POST /api/whatsapp/provision
{
"clinic_name": "Downtown Clinic",
"country": "US",
"area_code": "415"
}
Option C: Manual Entry (Settings UI)
In the MedSync app: go to Settings → Clinic → WhatsApp Number and enter the number. This simply stores the string — no Twilio integration is created. Use "Link Existing" for proper integration.
Message Flow
Inbound Messages (Patient → Clinic)
1. Patient sends WhatsApp message to clinic's registered number
2. Twilio receives it and POSTs to: POST /api/webhooks/whatsapp
Body: application/x-www-form-urlencoded
Fields: From, To, Body, ProfileName, MessageSid, etc.
Header: X-Twilio-Signature (HMAC-SHA1)
3. MedSync verifies Twilio signature (master token, then subaccount token fallback)
4. Routes to workspace by looking up the `To` number in whatsapp_senders
5. Upserts the patient by phone number (creates if new, sets consent_whatsapp=True)
6. Records the inbound message in whatsapp_messages table
7. Checks for slot shortcuts (patient replied "1", "2", or a time like "9:00")
- If match: resolves directly, skips AI
8. Otherwise: sends conversation history (last 8 messages) + available slots to Gemini
9. Gemini returns a natural-language reply + optional structured intent (book, cancel, etc.)
10. If intent = book: creates appointment with status "pending_review"
11. Returns TwiML <Message> with the reply text
12. Twilio delivers the reply to the patient
Outbound Messages (Clinic → Patient)
1. Doctor opens WhatsApp inbox in MedSync, selects a conversation, types a reply
2. Frontend calls: POST /api/whatsapp/send
{ "patient_id": "...", "body": "Hello!" }
3. Backend looks up the workspace's primary sender (from whatsapp_senders)
4. Calls twilio_client.send_whatsapp() which:
- Resolves the correct Twilio client (subaccount or master)
- Adds "whatsapp:" prefix to both To and From numbers
- Calls Twilio messages.create()
5. Records the outbound message in whatsapp_messages
6. Twilio delivers to patient via WhatsApp
63016. You must use a Meta-approved template message for business-initiated conversations.
Status Callbacks
Twilio POSTs delivery status updates to /api/webhooks/whatsapp/status:
| Status | Meaning |
|---|---|
queued | Message accepted by Twilio |
sent | Sent to WhatsApp |
delivered | Delivered to patient's device |
read | Patient opened the message |
failed | Delivery failed (check ErrorCode) |
undelivered | Could not be delivered |
The callback updates the message status in whatsapp_messages via provider_message_id (Twilio's MessageSid).
API Reference
Inbound webhook from Twilio. Unauthenticated (Twilio signature verified). Returns TwiML XML. Lives in backend/main.py.
Delivery status callback from Twilio. Updates message status in DB. Unauthenticated (signature verified).
Send an outbound message. Requires auth + X-Account-Id. Body: { patient_id, body }. Patient must have consent_whatsapp=true.
Send a template-based message. Body: { patient_id, template_id, variables, language }.
List all WhatsApp conversations for the active workspace.
Get all messages in a conversation.
Delete a conversation.
List appointments with status=pending_review (created by WhatsApp bot).
Accept a pending appointment. Changes status to confirmed and sends WhatsApp confirmation to patient.
Reject a pending appointment. Changes status to cancelled and notifies patient via WhatsApp.
List available message templates (static, defined in code).
Get reminder settings for the active workspace.
Update reminder settings. Body: { enabled, hours_before, template_key, custom_message, language }.
Auto-provision a Twilio subaccount + phone number. Owner/admin only. Body: { clinic_name, country, area_code }.
Link an already-registered WhatsApp sender to a workspace. Body: { phone_number, display_name, twilio_subaccount_sid?, twilio_subaccount_token? }.
Deactivate a sender (does not release the Twilio number).
Upload a .csv/.xlsx file and preview parsed recipients. Multipart form with file field.
Send bulk messages. Body: { body, recipients: [{name, phone, language, fields}], consent_confirmed }. Max 250 recipients.
Multi-Clinic Setup
Each MedSync workspace (clinic) can have its own dedicated WhatsApp number. This provides:
- Separate conversations per clinic
- Clinic-specific auto-replies and branding
- Optional billing isolation via Twilio subaccounts
How It Works
- Each workspace stores its sender(s) in the
whatsapp_senderstable withaccount_id - The first sender is marked
is_primary=true— this is the default "From" number for outbound messages - Up to 3 active senders per workspace
- Inbound messages are routed by the
Tonumber to the correct workspace
Subaccount Credentials (Optional)
If a workspace has its own Twilio subaccount, the subaccount SID and auth token are stored Fernet-encrypted in the whatsapp_senders table (twilio_subaccount_sid, twilio_subaccount_token).
When sending outbound messages, twilio_client.get_client_for_account() checks if the primary sender has subaccount credentials. If yes, it uses a subaccount Twilio client; otherwise falls back to the master client.
Signature Verification for Subaccounts
Twilio signs webhook requests with the auth token of the account that owns the number. For subaccount numbers, the master token won't verify. The inbound webhook handler tries:
- Master auth token
- If that fails: look up the sender by
Tonumber, decrypt its subaccount token, verify with that
WhatsApp Bot (Gemini-Powered)
When a patient messages the clinic, the bot uses Gemini 2.5 Flash to generate contextual, natural-language replies in the patient's language.
How It Works
- Conversation history (last 8 messages) is loaded from the database
- Available appointment slots are fetched via
get_available_slots() - A prompt is sent to Gemini with: conversation history, available slots, clinic info, current date/time
- Gemini returns a JSON response with:
reply(text to send),intent(book/cancel/question/greeting/farewell), and optionallyselected_slot - If the intent is
bookand a slot is selected: an appointment is created withstatus=pending_review
Slot Shortcuts
For faster booking, patients can reply with just a number ("1", "2") or a time ("9:00", "10:30"). These bypass Gemini entirely and resolve via whatsapp.resolve_slot_reply().
Appointment Status
- WhatsApp-booked appointments start as
pending_review - Doctors accept/reject via the WhatsApp inbox or notifications
- Accept →
confirmed+ sends WhatsApp confirmation to patient - Reject →
cancelled+ sends rejection message
Appointment Reminders
A background task (backend/reminders.py) runs every 5 minutes and sends WhatsApp reminders for upcoming appointments.
Configuration
Per workspace, configurable via Settings → WhatsApp → Reminder Settings or the API:
| Setting | Default | Description |
|---|---|---|
enabled | false | Must be explicitly enabled per workspace |
hours_before | 24 | How many hours before the appointment to send the reminder (1-72) |
language | es | Default language for reminders (en/es) |
template_key | reminder_default | Which reminder template to use |
custom_message | "" | Custom template with {patient_name}, {when}, {doctor_name} variables |
How It Works
- Every 5 minutes, queries all workspaces with
reminders.enabled=true - For each workspace, finds appointments in the upcoming window (now to now + hours_before)
- Checks that the appointment hasn't already been reminded
- Sends the reminder via the workspace's primary WhatsApp sender
- Records the reminder in the database to prevent duplicates
Bundle Messaging
Clinics can send messages to multiple patients at once (up to 250 per batch).
Two Input Methods
- File upload: Upload a
.csvor.xlsxfile withnameandphonecolumns. Preview withPOST /api/whatsapp/bundle/preview. - Manual entry: Type phone numbers directly in the "Type numbers" tab in the frontend.
Sending
POST /api/whatsapp/bundle/send
{
"body": "Hi {name}, your appointment is tomorrow at {time}.",
"recipients": [
{ "name": "John", "phone": "+1234567890", "fields": { "time": "9:00 AM" } },
{ "name": "Maria", "phone": "+0987654321", "fields": { "time": "10:30 AM" } }
],
"consent_confirmed": true
}
The body supports {variable} placeholders resolved per-recipient from name, phone, language, and any extra fields.
- Requires
owneroradmin_doctorrole consent_confirmedmust betrue- Maximum 250 valid recipients per request
- 24-hour window rule still applies — cold recipients may fail with error 63016
Message Templates
MedSync Templates (In-App)
Defined statically in backend/routers/whatsapp.py in the WHATSAPP_TEMPLATES list. These are not Meta-approved templates — they're convenience templates for the MedSync UI that generate freeform message bodies.
| ID | Category | Variables |
|---|---|---|
appt_reminder | Reminder | patient_name, date, time |
appt_confirmed | Confirmation | patient_name, date, time, doctor_name |
followup | Follow-up | patient_name |
pre_visit | Pre-visit | patient_name, date |
reschedule | Reschedule | patient_name, date |
custom | Custom | (freeform body) |
Meta Content Templates (Twilio)
For business-initiated messages outside the 24-hour window, you need Meta-approved content templates. These are managed in Twilio Console → Content Template Builder.
To send a Twilio content template via API:
# Python example using Twilio SDK
client.messages.create(
to="whatsapp:+1234567890",
from_="whatsapp:+15558887292",
content_sid="HX9ad6e4a83ef8200679db77af28c4a121", # template SID
content_variables=json.dumps({"1": "John", "2": "My Clinic", "3": "May 15, 2:00 PM"})
)
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
63016 |
Outside 24-hour customer service window | Patient must message first, or use a Meta-approved template message |
63112 |
Meta disabled the WABA connected to this sender | Usually caused by Twilio billing lapse. Recharge account, then file Twilio support ticket to re-sync the WABA. |
| HTTP 401 from Twilio API | Auth token mismatch OR zero account balance | Verify TWILIO_AUTH_TOKEN matches Twilio Console. Check account balance — zero balance returns 401, not a billing error. |
| Signature verification failed (403) | URL mismatch between what Twilio called and what MedSync sees | Set TWILIO_WEBHOOK_BASE_URL=https://your-public-domain.com. Check for http/https mismatch behind proxies. |
| "No active WhatsApp sender configured" | Workspace has no sender linked | Use POST /api/whatsapp/link-existing or the Settings UI to link a number. |
| "Patient has not consented to WhatsApp messages" | consent_whatsapp is false on the patient record |
Consent is auto-set when a patient messages in. For existing patients, update via the patient edit form. |
| "This number is already linked" | The phone number is already in whatsapp_senders for this workspace |
The number is already linked. Check the senders list. |
| "WhatsApp number is already linked to another workspace" | The number is linked to a different workspace | Each number can only be linked to one workspace. Deactivate it from the other workspace first. |
| Inbound messages not arriving | Webhook URL not configured on Twilio | Check Twilio Console → WhatsApp senders → Edit Sender → Webhook URL. Must point to /api/webhooks/whatsapp. |
| Bot replies in wrong language | Patient's preferred_language not set |
The bot defaults to the patient's stored language. Update via patient record or Gemini auto-detects from message content. |
Database Schema
whatsapp_senders
Each row is a WhatsApp-registered phone number linked to a workspace.
whatsapp_senders
├── id TEXT PRIMARY KEY
├── account_id TEXT (FK → accounts.id)
├── phone_number TEXT (e.g., "+15558887292")
├── provider TEXT (always "twilio")
├── provider_sender_id TEXT (Twilio Phone SID, e.g., "PNxxx")
├── display_name TEXT
├── status TEXT ("active" | "disabled")
├── is_primary BOOLEAN
├── twilio_subaccount_sid TEXT (Fernet-encrypted, optional)
├── twilio_subaccount_token TEXT (Fernet-encrypted, optional)
├── twilio_phone_sid TEXT (optional)
├── created_at TEXT (ISO 8601)
└── updated_at TEXT (ISO 8601)
whatsapp_conversations
One per patient-sender pair. Tracks conversation state for the bot.
whatsapp_conversations
├── id TEXT PRIMARY KEY
├── account_id TEXT
├── sender_id TEXT (FK → whatsapp_senders.id)
├── patient_id TEXT (FK → patients.id)
├── from_number TEXT
├── to_number TEXT
├── state TEXT ("idle" | "awaiting_slot" | "awaiting_confirm" | ...)
├── pending_intent TEXT (JSON — stores bot state between messages)
├── last_message_at TEXT
├── last_inbound_at TEXT
├── last_outbound_at TEXT
├── created_at TEXT
└── updated_at TEXT
whatsapp_messages
Every inbound and outbound message is recorded here.
whatsapp_messages
├── id TEXT PRIMARY KEY
├── account_id TEXT
├── sender_id TEXT
├── conversation_id TEXT (FK → whatsapp_conversations.id)
├── patient_id TEXT
├── provider TEXT ("twilio")
├── provider_message_id TEXT (Twilio MessageSid — used for status callback matching)
├── direction TEXT ("inbound" | "outbound")
├── from_number TEXT
├── to_number TEXT
├── body TEXT
├── status TEXT ("queued" | "sent" | "delivered" | "read" | "failed")
├── error TEXT (error details if failed)
├── appointment_id TEXT (if message is related to an appointment)
├── created_at TEXT
└── updated_at TEXT
File Map
| File | Purpose |
|---|---|
backend/main.py | Inbound webhook handler (POST /api/webhooks/whatsapp). This is where patient messages arrive and TwiML replies are generated. |
backend/whatsapp.py | Core WhatsApp logic: Twilio signature verification, Gemini intent parsing, slot resolution, reply text generation, TwiML helpers. |
backend/twilio_client.py | Twilio REST client factory. Supports master account and per-clinic subaccounts. Handles send_whatsapp(), subaccount creation, number purchase, webhook config. |
backend/reminders.py | Background task that sends WhatsApp appointment reminders every 5 minutes. |
backend/routers/whatsapp.py | All authenticated REST API endpoints: send messages, list conversations, templates, bundle messaging, provision/link senders, reminder settings, pending appointments. |
backend/database/__init__.py | Database helpers for whatsapp_senders, whatsapp_conversations, whatsapp_messages tables. |
frontend/whatsapp.jsx | React UI for the WhatsApp inbox: Conversations tab (real-time chat), Bundle messages tab (bulk send). |
MedSync