Lesson 04 · WhatsApp Business API for your app

The Twilio path, end to end

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.

~12 min Knowledge + code Builds on: Lesson 03
The one sentence With Twilio you still use Meta's Embedded Signup for the connect button, but instead of storing a Meta token you create a Twilio subaccount per org, register the org's number via the Senders API, and send templates through Twilio's Content API — authenticating to Twilio, not Meta.

1. What actually changes vs. direct Meta

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.

Your app (Python/FastAPI)
Auths to Twilio with account credentials. Never touches a Meta token.
▼ Twilio REST API
Twilio
Is the Tech Provider to Meta. Holds each org's WABA connection inside a dedicated subaccount. Adds a per-message fee.
▼ Cloud API
Meta / WhatsApp
Still owns the org's WABA + number + template approval + quality rating.
Parent
Sees the message from the school's number, exactly as before.
Direct Meta (Lesson 03)Twilio (this lesson)
Who you authenticate toMeta Graph API (Bearer BISU token)Twilio (Account SID + Auth Token)
The stored secretMeta BISU access tokenTwilio subaccount Auth Token
Multi-tenancy unitA row keyed by org_idA Twilio subaccount per org (1 subaccount = 1 WABA)
Register the numberMeta register phone endpointTwilio Senders API (returns XE… sender SID)
Send a template/{phone_number_id}/messagesMessages API with a ContentSid
CostMeta rate only (₹0.115 utility)Meta rate + Twilio fee

[Twilio: Tech Provider integration guide]

2. What you store per org (generalising Lesson 03)

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)
}
Why the secret is the subaccount Auth Token That token lets you send messages billed to that org's subaccount and nothing else — same blast radius rule as the Meta token, so it gets the same treatment: encrypted at rest, decrypted only at send time, never logged.

3. Onboarding an org with Twilio

The connect button is still Meta's Embedded Signup (Twilio uses Embedded Signup V4). What differs is what your backend does with the result.

  1. Org clicks "Connect WhatsApp" → Meta Embedded Signup popup → returns waba_id + phone_number_id (same as Lesson 01, step 2–4).
  2. Create a Twilio subaccount for this org (Accounts API). 1 subaccount = 1 WABA.
  3. Register the sender under that subaccount via the Senders API (POST /v2/Channels/Senders) → returns an XE… sender SID; status goes CREATING → OFFLINE → ONLINE.
  4. Persist using Lesson 03's 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()
Two Twilio gotchas Rate limits: leave several minutes between Senders API calls or you'll get errors. Sender readiness: a sender is only sendable once its status reaches online — don't flip your tenant to connected until then (poll or handle the status webhook). [Twilio: Senders API]

4. The TwilioProvider — same interface, new body

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]

5. Templates live in Twilio's Content Template Builder

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.

6. Cost: Meta rate + Twilio fee

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.

Twilio path, in one loop Embedded Signup → onboard_twilio (subaccount + Senders API) → store (secret = subaccount token, config = sids + template map) → provider_for returns a TwilioProvidersend_billing_reminder sends via ContentSid → auth failure raises the same ProviderAuthError → reconnect.

Check yourself

Q1. In Twilio's model, each customer org maps to one:

Q2. On the Twilio path, the encrypted secret you store is the:

Q3. Which identifier names the template when sending via Twilio?

Q4. Why does adopting Twilio barely touch your app code?

Ask me: "Write the Content API call that creates + submits the billing template", "How do I poll the sender until it's online?", or "Show the FastAPI route that runs 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).