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
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. |
| 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. |
| 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. |
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. |
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 → 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)
- 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
- 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"
}
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 — 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 |
API Reference
Inbound webhook from Twilio. Unauthenticated (Twilio signature verified). Returns TwiML XML.
Delivery status callback from Twilio. Updates message status in DB.
Send an outbound message. Requires auth + X-Account-Id. Body: { patient_id, body }.
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.
Reject a pending appointment. Changes status to cancelled and notifies patient.
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.
Link an already-registered WhatsApp sender to a workspace.
Deactivate a sender (does not release the Twilio number).
Upload a .csv/.xlsx file and preview parsed recipients.
Send bulk messages. Max 250 recipients. Body: { body, recipients, consent_confirmed }.
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.
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. 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,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
| 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
- 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.
owner or admin_doctor role. consent_confirmed must be true. Maximum 250 valid recipients per request. 24-hour window rule still applies.
Message Templates
MedSync Templates (In-App)
Defined statically in backend/routers/whatsapp.py. These are convenience templates for the MedSync UI that generate freeform message bodies (not Meta-approved).
| 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 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",
content_variables=json.dumps({"1": "John", "2": "My Clinic", "3": "May 15, 2:00 PM"})
)
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
63016 | Outside 24-hour window | Patient must message first, or use a Meta-approved template |
63112 | Meta disabled the WABA | Recharge Twilio account, file support ticket to re-sync WABA |
| HTTP 401 from Twilio | Auth token mismatch or zero balance | Verify TWILIO_AUTH_TOKEN. Check account balance. |
| Signature verification failed (403) | URL mismatch | Set TWILIO_WEBHOOK_BASE_URL to exact public https URL |
| "No active WhatsApp sender" | Workspace has no sender linked | Use POST /api/whatsapp/link-existing or Settings UI |
| "Patient has not consented" | consent_whatsapp is false | Consent is auto-set on first inbound message. Update via patient edit form. |
| "Number already linked" | Phone number exists in whatsapp_senders | Check the senders list for this workspace |
| "Linked to another workspace" | Number belongs to different workspace | Deactivate from the other workspace first |
| Inbound messages not arriving | Webhook URL not configured | Check Twilio Console → WhatsApp senders → Webhook URL |
| Bot replies in wrong language | Patient language not set | Update via patient record or let Gemini auto-detect |
Database Schema
whatsapp_senders
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)
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
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)
last_message_at TEXT
last_inbound_at TEXT
last_outbound_at TEXT
created_at TEXT
updated_at TEXT
whatsapp_messages
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)
direction TEXT ("inbound" | "outbound")
from_number TEXT
to_number TEXT
body TEXT
status TEXT ("queued" | "sent" | "delivered" | "read" | "failed")
error TEXT
appointment_id TEXT
created_at TEXT
updated_at TEXT
File Map
| File | Purpose |
|---|---|
backend/main.py | Inbound webhook handler (POST /api/webhooks/whatsapp). Patient messages arrive here. |
backend/whatsapp.py | Core logic: Twilio signature verification, Gemini intent parsing, slot resolution, TwiML helpers. |
backend/twilio_client.py | Twilio REST client factory. Master + per-clinic subaccounts. send_whatsapp(), subaccount creation, number purchase. |
backend/reminders.py | Background task: WhatsApp appointment reminders every 5 minutes. |
backend/routers/whatsapp.py | All authenticated REST API endpoints: send, conversations, templates, bundle, provision, reminders. |
backend/database/__init__.py | Database helpers for whatsapp_senders, conversations, messages tables. |
frontend/whatsapp.jsx | React UI: Conversations tab (real-time chat) + Bundle messages tab (bulk send). |
