Skip to content

WhatsApp Cloud API

The WhatsApp Cloud API is plain HTTP: you POST messages to Meta's Graph API, and Meta POSTs inbound messages and delivery statuses back to a webhook you host. Those are exactly the two halves of the http addon — the pooled http.Client for the outbound Graph calls, and @http.route for the inbound webhook — so a full bridge is a script, with no protocol code.

This mirrors examples/whatsapp_cloud_api.py.

Inbound webhook

Meta first does a GET verify handshake, then POSTs events signed with an HMAC over the raw body. Both live on the same path:

import hashlib, hmac, json, os
from siphon import http, cache, log

APP_SECRET = os.environ.get("WHATSAPP_APP_SECRET", "")
VERIFY_TOKEN = os.environ.get("WHATSAPP_VERIFY_TOKEN", "")


def verify_signature(app_secret, raw_body, signature_header):
    # header is "sha256=<hex>", hex = HMAC-SHA256(app_secret, RAW body)
    if not signature_header or not signature_header.startswith("sha256="):
        return False
    expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header[len("sha256="):])


@http.route("/whatsapp/webhook", methods=["GET"])
async def verify_webhook(req):
    p = req.query_params
    if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == VERIFY_TOKEN:
        return http.Response(status=200, body=p.get("hub.challenge", "").encode())
    return http.Response(status=403, body=b"forbidden\n")


@http.route("/whatsapp/webhook", methods=["POST"])
async def receive_webhook(req):
    raw = req.body() or b""
    if not verify_signature(APP_SECRET, raw, req.header("x-hub-signature-256")):
        return http.Response(status=403, body=b"bad signature\n")
    payload = json.loads(raw or b"{}")
    # ... iterate payload["entry"][*]["changes"][*]["value"]["messages"/"statuses"]
    return http.Response(status=200)     # 2xx fast so Meta does not re-deliver

Two things are load-bearing:

  • Verify the signature over the RAW bytes. req.body() returns the exact buffered bytes Meta signed; re-serialising the parsed JSON would change the bytes and break the HMAC. Compare in constant time (hmac.compare_digest).
  • Answer 2xx quickly. Meta re-delivers a webhook until it gets a 200, so do slow work after responding (or off a background task), and dedup on the message id — Meta may deliver the same message more than once:
async def already_seen(message_id):
    if await cache.exists("wa_dedup", message_id):
        return True
    await cache.store("wa_dedup", message_id, "1", ttl=3600)
    return False

Back the wa_dedup cache with Redis to dedup across replicas; a local LRU dedups within one process.

Outbound (Graph API)

A named, pooled client sends the Graph POST. The access token is a per-request Authorization: Bearer header:

TOKEN = os.environ.get("WHATSAPP_TOKEN", "")
PHONE_NUMBER_ID = os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "")


async def send_text(to, text):
    body = json.dumps({
        "messaging_product": "whatsapp",
        "to": to,
        "type": "text",
        "text": {"body": text},
    }).encode()
    headers = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
    async with http.Client("graph") as client:
        response = await client.post(f"/{PHONE_NUMBER_ID}/messages", body=body, headers=headers)
    return response.status

Messages outside the 24-hour customer-service window must be templatesbuild_template_message in the example builds those.

Config

# http.yaml (referenced from siphon.yaml `extensions.http`)
clients:
  graph:
    base_url: "https://graph.facebook.com/v20.0"   # version lives in the base URL
    timeout_ms: 5000
servers:
  - listen: "0.0.0.0:8443"
    max_body_bytes: 262144
    tls:                          # or terminate TLS at a fronting LB (Meta needs HTTPS)
      cert_path: "/etc/siphon/tls/webhook.crt"
      key_path: "/etc/siphon/tls/webhook.key"
# siphon.yaml
script:
  path: "examples/whatsapp_cloud_api.py"
cache:
  - name: "wa_dedup"
    url: "redis://localhost:6379"    # omit `url` for a process-local LRU
    local_ttl_secs: 3600
extensions:
  http: http.yaml

Keep secrets out of the files — the example reads WHATSAPP_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKEN, and WHATSAPP_PHONE_NUMBER_ID from the environment.

Try it

# Meta's verify handshake (should echo the challenge value):
curl -sS "localhost:8443/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=$WHATSAPP_VERIFY_TOKEN&hub.challenge=1234"

# A signed event POST:
BODY='{"entry":[{"changes":[{"value":{"messages":[{"id":"wamid.1","from":"15551234567","type":"text","text":{"body":"hi"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WHATSAPP_APP_SECRET" -r | cut -d' ' -f1)
curl -sS -XPOST localhost:8443/whatsapp/webhook \
     -H "x-hub-signature-256: sha256=$SIG" -H 'content-type: application/json' -d "$BODY"

See also

  • WhatsApp voice. WhatsApp also does voice, over SIP (the Business Calling API). That is a separate, SIP-side integration handled by siphon itself — see the WhatsApp calling cookbook.
  • SMS. siphon also ships an smpp addon. The same compose-a-binary + script pattern lets you gateway messages between WhatsApp and SMS — but confirm Meta's WhatsApp Business terms permit routing WhatsApp content to SMS/SMPP before you build that; it is commonly restricted.