/ Documentation
All Docs Open App

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.

Prerequisites You need a Twilio account, a registered WhatsApp Business sender, and the required environment variables set before any WhatsApp features work.

Architecture

High-Level Flow

Patient (WhatsApp)
Twilio
POST /api/webhooks/whatsapp
Gemini AI (intent parsing)
TwiML response
Twilio
Patient

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:

  1. Twilio POSTs to /api/webhooks/whatsapp with From (patient) and To (clinic sender number)
  2. MedSync looks up the sender by the To number in the whatsapp_senders table
  3. The sender's account_id determines which workspace (clinic) owns this conversation
  4. The patient is upserted into that workspace's patient list

Key Concepts

TermDescription
WABAWhatsApp Business Account — a Meta entity that holds phone numbers. One WABA can have multiple numbers. Created via Twilio Console → Facebook Embedded Signup.
SenderA WhatsApp-registered phone number under a WABA. Each MedSync workspace links to one primary sender.
24-Hour WindowWhatsApp 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 MessagePre-approved message format (by Meta) required for business-initiated conversations. Has variables like {{1}}, {{2}}.
TwiMLTwilio Markup Language — XML returned from the webhook to tell Twilio what reply to send. MedSync uses <Message> TwiML.
WorkspaceA MedSync clinic account. Each workspace can have up to 3 active WhatsApp senders.
SubaccountA 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):

VariableRequiredDescription
TWILIO_ACCOUNT_SIDYesMaster Twilio Account SID (starts with AC)
TWILIO_AUTH_TOKENYesMaster Twilio Auth Token (used for API calls and signature verification)
TWILIO_WEBHOOK_BASE_URLYes (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_KEYYesGoogle Gemini API key for the AI-powered WhatsApp bot.
ALLOWED_ORIGINSFallbackIf TWILIO_WEBHOOK_BASE_URL is not set, the first origin is used for status callback URLs.
Signature Verification Gotcha Twilio HMACs the exact URL it called. If your app sits behind a reverse proxy (Cloud Run, nginx) that rewrites 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.

  1. Go to Twilio Console → Messaging → Senders → WhatsApp senders
  2. Click "Create new sender"
  3. 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.
  4. Click Continue to proceed to the Facebook Embedded Signup flow
  5. 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)
  6. Complete the flow. The sender will appear as "Online" in the Twilio Console.
Personal WhatsApp Conflict If your number is already registered on the regular WhatsApp app, you'll get an error: "This number is registered to an existing WhatsApp account." You must disconnect (delete) the WhatsApp account on that number first. WhatsApp "coexistence mode" exists but is not supported through Twilio's embedded signup flow — it requires direct Meta Cloud API integration.

3. Configure Webhooks on Twilio

After the sender is registered, configure the webhook URLs:

  1. Go to Twilio Console → WhatsApp senders → click "Edit Sender" on your number
  2. Set these fields:
    FieldValue
    Webhook URL for incoming messageshttps://your-domain.com/api/webhooks/whatsapp
    Webhook methodHTTP Post
    Status callback URLhttps://your-domain.com/api/webhooks/whatsapp/status
  3. Click "Update WhatsApp Sender"

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"
}
Note: Auto-provision buys a Twilio number and sets up SMS webhooks, but you still need to register it as a WhatsApp sender via the Twilio Console (step 2 above). The bought number is a regular phone number until WhatsApp registration is completed.

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
24-Hour Window Rule Freeform outbound messages only work if the patient messaged within the last 24 hours. Outside this window, Twilio returns error 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:

StatusMeaning
queuedMessage accepted by Twilio
sentSent to WhatsApp
deliveredDelivered to patient's device
readPatient opened the message
failedDelivery failed (check ErrorCode)
undeliveredCould not be delivered

The callback updates the message status in whatsapp_messages via provider_message_id (Twilio's MessageSid).

API Reference

POST /api/webhooks/whatsapp

Inbound webhook from Twilio. Unauthenticated (Twilio signature verified). Returns TwiML XML. Lives in backend/main.py.

POST /api/webhooks/whatsapp/status

Delivery status callback from Twilio. Updates message status in DB. Unauthenticated (signature verified).

POST /api/whatsapp/send

Send an outbound message. Requires auth + X-Account-Id. Body: { patient_id, body }. Patient must have consent_whatsapp=true.

POST /api/whatsapp/send-template

Send a template-based message. Body: { patient_id, template_id, variables, language }.

GET /api/whatsapp/conversations

List all WhatsApp conversations for the active workspace.

GET /api/whatsapp/conversations/{id}/messages

Get all messages in a conversation.

DELETE /api/whatsapp/conversations/{id}

Delete a conversation.

GET /api/whatsapp/pending-appointments

List appointments with status=pending_review (created by WhatsApp bot).

POST /api/whatsapp/appointments/{id}/accept

Accept a pending appointment. Changes status to confirmed and sends WhatsApp confirmation to patient.

POST /api/whatsapp/appointments/{id}/reject

Reject a pending appointment. Changes status to cancelled and notifies patient via WhatsApp.

GET /api/whatsapp/templates

List available message templates (static, defined in code).

GET /api/whatsapp/reminders/settings

Get reminder settings for the active workspace.

PUT /api/whatsapp/reminders/settings

Update reminder settings. Body: { enabled, hours_before, template_key, custom_message, language }.

POST /api/whatsapp/provision

Auto-provision a Twilio subaccount + phone number. Owner/admin only. Body: { clinic_name, country, area_code }.

POST /api/whatsapp/link-existing

Link an already-registered WhatsApp sender to a workspace. Body: { phone_number, display_name, twilio_subaccount_sid?, twilio_subaccount_token? }.

DELETE /api/whatsapp/senders/{id}

Deactivate a sender (does not release the Twilio number).

POST /api/whatsapp/bundle/preview

Upload a .csv/.xlsx file and preview parsed recipients. Multipart form with file field.

POST /api/whatsapp/bundle/send

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:

How It Works

  1. Each workspace stores its sender(s) in the whatsapp_senders table with account_id
  2. The first sender is marked is_primary=true — this is the default "From" number for outbound messages
  3. Up to 3 active senders per workspace
  4. Inbound messages are routed by the To number 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:

  1. Master auth token
  2. If that fails: look up the sender by To number, 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

  1. Conversation history (last 8 messages) is loaded from the database
  2. Available appointment slots are fetched via get_available_slots()
  3. A prompt is sent to Gemini with: conversation history, available slots, clinic info, current date/time
  4. Gemini returns a JSON response with: reply (text to send), intent (book/cancel/question/greeting/farewell), and optionally selected_slot
  5. If the intent is book and a slot is selected: an appointment is created with status=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

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:

SettingDefaultDescription
enabledfalseMust be explicitly enabled per workspace
hours_before24How many hours before the appointment to send the reminder (1-72)
languageesDefault language for reminders (en/es)
template_keyreminder_defaultWhich reminder template to use
custom_message""Custom template with {patient_name}, {when}, {doctor_name} variables

How It Works

  1. Every 5 minutes, queries all workspaces with reminders.enabled=true
  2. For each workspace, finds appointments in the upcoming window (now to now + hours_before)
  3. Checks that the appointment hasn't already been reminded
  4. Sends the reminder via the workspace's primary WhatsApp sender
  5. 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

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.

Requirements

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.

IDCategoryVariables
appt_reminderReminderpatient_name, date, time
appt_confirmedConfirmationpatient_name, date, time, doctor_name
followupFollow-uppatient_name
pre_visitPre-visitpatient_name, date
rescheduleReschedulepatient_name, date
customCustom(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"})
)
Template Approval Template approval by Meta can take 24-48 hours. Templates must follow Meta's guidelines — no promotional content for Utility category. Check status in Twilio Console → Content Template Builder.

Troubleshooting

ErrorCauseFix
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

FilePurpose
backend/main.pyInbound webhook handler (POST /api/webhooks/whatsapp). This is where patient messages arrive and TwiML replies are generated.
backend/whatsapp.pyCore WhatsApp logic: Twilio signature verification, Gemini intent parsing, slot resolution, reply text generation, TwiML helpers.
backend/twilio_client.pyTwilio REST client factory. Supports master account and per-clinic subaccounts. Handles send_whatsapp(), subaccount creation, number purchase, webhook config.
backend/reminders.pyBackground task that sends WhatsApp appointment reminders every 5 minutes.
backend/routers/whatsapp.pyAll authenticated REST API endpoints: send messages, list conversations, templates, bundle messaging, provision/link senders, reminder settings, pending appointments.
backend/database/__init__.pyDatabase helpers for whatsapp_senders, whatsapp_conversations, whatsapp_messages tables.
frontend/whatsapp.jsxReact UI for the WhatsApp inbox: Conversations tab (real-time chat), Bundle messages tab (bulk send).