MedSync / Documentation

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
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
  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.
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.
WorkspaceA MedSync clinic account. Each workspace can have up to 3 active WhatsApp senders.
SubaccountA 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):

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.
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 → 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)
  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
    • 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. You must disconnect (delete) the WhatsApp account on that number first.

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"
}

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).

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
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

API Reference

POST/api/webhooks/whatsapp

Inbound webhook from Twilio. Unauthenticated (Twilio signature verified). Returns TwiML XML.

POST/api/webhooks/whatsapp/status

Delivery status callback from Twilio. Updates message status in DB.

POST/api/whatsapp/send

Send an outbound message. Requires auth + X-Account-Id. Body: { patient_id, body }.

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.

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

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

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.

POST/api/whatsapp/link-existing

Link an already-registered WhatsApp sender to a workspace.

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.

POST/api/whatsapp/bundle/send

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:

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.

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:

  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, 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

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
  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 Requires 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).

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 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"})
)
Template Approval Template approval by Meta can take 24-48 hours. Templates must follow Meta's guidelines. Check status in Twilio Console → Content Template Builder.

Troubleshooting

ErrorCauseFix
63016Outside 24-hour windowPatient must message first, or use a Meta-approved template
63112Meta disabled the WABARecharge Twilio account, file support ticket to re-sync WABA
HTTP 401 from TwilioAuth token mismatch or zero balanceVerify TWILIO_AUTH_TOKEN. Check account balance.
Signature verification failed (403)URL mismatchSet TWILIO_WEBHOOK_BASE_URL to exact public https URL
"No active WhatsApp sender"Workspace has no sender linkedUse POST /api/whatsapp/link-existing or Settings UI
"Patient has not consented"consent_whatsapp is falseConsent is auto-set on first inbound message. Update via patient edit form.
"Number already linked"Phone number exists in whatsapp_sendersCheck the senders list for this workspace
"Linked to another workspace"Number belongs to different workspaceDeactivate from the other workspace first
Inbound messages not arrivingWebhook URL not configuredCheck Twilio Console → WhatsApp senders → Webhook URL
Bot replies in wrong languagePatient language not setUpdate 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

FilePurpose
backend/main.pyInbound webhook handler (POST /api/webhooks/whatsapp). Patient messages arrive here.
backend/whatsapp.pyCore logic: Twilio signature verification, Gemini intent parsing, slot resolution, TwiML helpers.
backend/twilio_client.pyTwilio REST client factory. Master + per-clinic subaccounts. send_whatsapp(), subaccount creation, number purchase.
backend/reminders.pyBackground task: WhatsApp appointment reminders every 5 minutes.
backend/routers/whatsapp.pyAll authenticated REST API endpoints: send, conversations, templates, bundle, provision, reminders.
backend/database/__init__.pyDatabase helpers for whatsapp_senders, conversations, messages tables.
frontend/whatsapp.jsxReact UI: Conversations tab (real-time chat) + Bundle messages tab (bulk send).