Lesson 04 · WhatsApp Business API for your app
You want to do this with Twilio. Here's exactly how the pieces map: Twilio sits
between you and Meta, each org becomes a Twilio subaccount, and your Lesson 03
MessagingProvider gets one new implementation. Nothing else in your app changes.
Same mental model as Lesson 01, one extra hop. Twilio holds the Meta relationship; you hold a Twilio relationship. This is the BSP path from Lesson 02, made concrete.
| Direct Meta (Lesson 03) | Twilio (this lesson) | |
|---|---|---|
| Who you authenticate to | Meta Graph API (Bearer BISU token) | Twilio (Account SID + Auth Token) |
| The stored secret | Meta BISU access token | Twilio subaccount Auth Token |
| Multi-tenancy unit | A row keyed by org_id | A Twilio subaccount per org (1 subaccount = 1 WABA) |
| Register the number | Meta register phone endpoint | Twilio Senders API (returns XE… sender SID) |
| Send a template | /{phone_number_id}/messages | Messages API with a ContentSid |
| Cost | Meta rate only (₹0.115 utility) | Meta rate + Twilio fee |
[Twilio: Tech Provider integration guide]
The beauty of the Lesson 03 design: the credential shape is totally different, but the
table barely changes. We rename the secret column and add a small non-secret config JSON.
# models.py — generalised so one table serves both providers
from sqlalchemy import String, LargeBinary, JSON, Enum
from sqlalchemy.orm import Mapped, mapped_column
class OrgWhatsApp(Base):
__tablename__ = "org_whatsapp"
org_id: Mapped[str] = mapped_column(String, primary_key=True)
provider: Mapped[str] = mapped_column(String) # "meta" | "twilio"
waba_id: Mapped[str] = mapped_column(String)
phone_number_id: Mapped[str] = mapped_column(String)
secret_enc: Mapped[bytes] = mapped_column(LargeBinary) # ENCRYPTED: Meta BISU token OR Twilio subaccount token
config: Mapped[dict] = mapped_column(JSON, default=dict) # non-secret provider bits
status: Mapped[WaStatus] = mapped_column(Enum(WaStatus), default=WaStatus.connected)
For a Twilio org, config holds:
{
"twilio_subaccount_sid": "AC…", // the customer's subaccount
"wa_from": "whatsapp:+919812345678", // the org's sender, whatsapp: prefixed
"sender_sid": "XE…", // from the Senders API
"templates": { "billing_reminder_v1": "HX…" } // logical name -> ContentSid (per WABA)
}
The connect button is still Meta's Embedded Signup (Twilio uses Embedded Signup V4). What differs is what your backend does with the result.
waba_id + phone_number_id (same as Lesson 01, step 2–4).POST /v2/Channels/Senders) → returns an XE… sender SID; status goes
CREATING → OFFLINE → ONLINE.save_connection shape — the encrypted
secret is now the subaccount Auth Token.# onboarding_twilio.py
from twilio.rest import Client
from .settings import settings
from .crypto import encrypt
from .models import OrgWhatsApp, WaStatus
# YOUR parent account — used only to spawn subaccounts
parent = Client(settings.twilio_account_sid, settings.twilio_auth_token)
def onboard_twilio(org_id, waba_id, phone_number_id, wa_number_e164, display_name, session):
# 1) one subaccount per customer WABA
sub = parent.api.v2010.accounts.create(friendly_name=f"org:{org_id}")
# 2) register the WhatsApp sender *inside that subaccount*
sub_client = Client(sub.sid, sub.auth_token)
sender = sub_client.messaging.v2.channels_senders.create(
messaging_v2_channels_sender_requests_create={
"sender_id": wa_number_e164, # E.164, e.g. "+919812345678"
"profile": {"name": display_name},
# + inbound & status-callback webhook URLs (Lesson 06)
}
) # -> sender.sid starts "XE"; poll until status == "online"
# 3) persist — encrypt the subaccount token (the sendable secret)
session.merge(OrgWhatsApp(
org_id=org_id, provider="twilio",
waba_id=waba_id, phone_number_id=phone_number_id,
secret_enc=encrypt(sub.auth_token),
config={
"twilio_subaccount_sid": sub.sid,
"wa_from": f"whatsapp:{wa_number_e164}",
"sender_sid": sender.sid,
"templates": {}, # filled as templates get approved
},
status=WaStatus.connected,
))
session.commit()
online
— don't flip your tenant to connected until then (poll or handle the status webhook).
[Twilio: Senders API]
This is the whole point of Lesson 03's MessagingProvider Protocol. Your
send_billing_reminder use-case is unchanged; only this class is new.
# twilio_provider.py
import json
from twilio.rest import Client
from twilio.base.exceptions import TwilioRestException
from .provider import SendResult, ProviderAuthError
class TwilioProvider:
def __init__(self, subaccount_sid: str, auth_token: str, wa_from: str, templates: dict[str, str]):
self._client = Client(subaccount_sid, auth_token) # auth AS the org's subaccount
self._from = wa_from # "whatsapp:+91…"
self._templates = templates # logical name -> ContentSid
def send_template(self, *, phone_number_id, to, template, lang, variables) -> SendResult:
# Twilio addresses by wa_from, not phone_number_id — arg kept for interface parity.
content_sid = self._templates[template] # e.g. "HX…", approved per WABA
content_vars = json.dumps({str(i): v for i, v in enumerate(variables, start=1)})
try:
msg = self._client.messages.create(
from_=self._from,
to=f"whatsapp:{to}",
content_sid=content_sid,
content_variables=content_vars, # {"1": "...", "2": "..."}
)
except TwilioRestException as e:
if e.status in (401, 403) or e.code == 20003: # auth failed -> treat like revocation
raise ProviderAuthError(str(e))
raise
return SendResult(message_id=msg.sid)
And the factory from Lesson 03 grows one branch — nothing else moves:
# factory.py
def provider_for(org_id, session):
row = session.get(OrgWhatsApp, org_id)
if row is None or row.status != WaStatus.connected:
raise RuntimeError(f"org {org_id} has no active WhatsApp connection")
secret = decrypt(row.secret_enc)
if row.provider == "meta":
return MetaCloudProvider(secret), row
if row.provider == "twilio":
c = row.config
return TwilioProvider(c["twilio_subaccount_sid"], secret, c["wa_from"], c["templates"]), row
raise RuntimeError(f"unknown provider {row.provider!r}")
send_billing_reminder(...) from Lesson 03 calls this and needs
zero changes. That's the portability you were promised in Lesson 02.
[Twilio: send Content templates]
Instead of submitting raw templates to Meta, you build them in Twilio's Content Template Builder
(or Content API); Twilio submits them to Meta for WhatsApp approval. Each approved template gives you
a ContentSid (HX…). Because approval is tied to a WABA, store the
logical-name → ContentSid map per org (the config.templates field).
Your billing reminder stays a utility template (Lesson 02) — category still drives cost.
Twilio charges its own per-message fee on top of Meta's rate — this is the BSP margin from
Lesson 02. So an India utility message ≈ Meta ₹0.115 + Twilio's per-message WhatsApp
fee. Trivial per message, but it's the recurring cost that eventually justifies migrating to direct
Meta — and because your app only knows MessagingProvider, that migration is swapping
TwilioProvider for MetaCloudProvider, not a rewrite. Confirm live numbers on
Twilio's WhatsApp pricing.
onboard_twilio (subaccount + Senders API) → store (secret = subaccount
token, config = sids + template map) → provider_for returns a TwilioProvider →
send_billing_reminder sends via ContentSid → auth failure raises the same
ProviderAuthError → reconnect.
onboard_twilio after Embedded Signup." Next lesson wires the actual connect button.
Primary sources: Twilio — Tech Provider integration guide · Senders API · Send Content templates.
Prev: Lesson 03 — Tenant storage · Reference: Glossary · Next: Lesson 05 — the Embedded Signup button (shared by both paths).