Bird

Store SMS delivery receipts with FastAPI

Connect message outcomes to the application record that caused the send. This receiver verifies the original body, commits each event once and records the latest observed message state. It also stores incoming replies for a separate application worker.

1. Configure the receiver

Use Python 3.10 or later. In a new virtual environment, install messagebird-sdk and "fastapi[standard]". Set BIRD_WEBHOOK_SECRET to the signing secret returned when you create a webhook endpoint. Store it outside source control.
Register a reachable HTTPS endpoint for the event names you need: sms.accepted, sms.sent, sms.delivered, sms.undelivered, sms.failed, sms.expired, sms.rejected and sms.received. The subscription requires explicit names; sms.* is not a wildcard subscription. For a local server, use an HTTPS tunnel you control; Bird cannot deliver to localhost.

2. Persist verified events

Save this as receiver.py. SQLite keeps the example runnable on one machine. Use a durable shared database for multiple application instances; keep the unique event key and transaction together.
Code example
import json
import os
import sqlite3
from datetime import datetime

from bird import Bird, WebhookVerificationError
from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()
DB = os.environ.get("SMS_EVENTS_DB", "sms-events.sqlite3")
STATUSES = {
    "sms.accepted": "accepted", "sms.sent": "sent",
    "sms.delivered": "delivered", "sms.undelivered": "undelivered",
    "sms.failed": "failed", "sms.expired": "expired", "sms.rejected": "rejected",
}


def database():
    db = sqlite3.connect(DB, timeout=5)
    db.execute("CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, body TEXT NOT NULL)")
    db.execute("""CREATE TABLE IF NOT EXISTS outcomes (
        workspace_id TEXT, sms_id TEXT, status TEXT, observed_at REAL,
        PRIMARY KEY (workspace_id, sms_id))""")
    db.commit()
    return db


@app.post("/webhooks/bird")
async def receive(request: Request):
    raw = await request.body()
    try:
        with Bird(webhook_secret=os.environ["BIRD_WEBHOOK_SECRET"]) as bird:
            event = bird.webhooks.unwrap(raw, dict(request.headers)).root
    except WebhookVerificationError:
        raise HTTPException(400, "Invalid webhook signature or payload.")
    event_id = request.headers["webhook-id"]
    body = event.model_dump(mode="json", by_alias=True)
    if body["type"] not in STATUSES and body["type"] != "sms.received":
        return Response(status_code=204)
    data = body["data"]
    observed_at = datetime.fromisoformat(body["timestamp"].replace("Z", "+00:00")).timestamp()
    db = database()
    try:
        with db:
            added = db.execute("INSERT OR IGNORE INTO events VALUES (?, ?)",
                               (event_id, json.dumps(body))).rowcount
            if added and body["type"] in STATUSES:
                db.execute("""INSERT INTO outcomes VALUES (?, ?, ?, ?)
                    ON CONFLICT(workspace_id, sms_id) DO UPDATE SET
                      status=excluded.status, observed_at=excluded.observed_at
                    WHERE excluded.observed_at > outcomes.observed_at""",
                    (data["workspace_id"], data["sms_id"], STATUSES[body["type"]], observed_at))
    finally:
        db.close()
    return Response(status_code=204)
Start it with fastapi dev receiver.py --host 127.0.0.1. Point your tunnel at its port, then use the HTTPS URL ending in /webhooks/bird for the registered endpoint.
The signature timestamp verifies freshness. The envelope's timestamp orders the message observations; these timestamps have different jobs. Deduplicate by webhook-id, which remains stable across retries. The receiver acknowledges only after the database transaction commits. A database failure returns an error, allowing Bird to retry.

3. Inspect delivery and replay behavior

Send a first SMS, retain its message ID, then inspect the stored result in a second terminal:
Code example
python -c 'import sqlite3; db=sqlite3.connect("sms-events.sqlite3"); print(db.execute("SELECT sms_id, status FROM outcomes").fetchall()); db.close()'
Test a valid event twice: the events table should contain one row for that webhook-id. Deliver an older sms.sent after a newer sms.delivered: the stored status should remain delivered. Send an altered body or an invalid signature: the receiver should return 400 without storing it.
The endpoint test operation sends a minimal connectivity stub, not a complete SMS event. This strict SDK receiver validates the SMS schema; use a controlled SMS send for the complete payload test. See webhook testing.

4. Use the observation correctly

ObservationApplication meaning
acceptedBird has accepted the message; delivery remains pending.
sentThe message was handed to the carrier.
deliveredThe carrier confirmed delivery.
undelivered, failed, expired, rejectedInspect the corresponding event details before choosing a recovery action.
No final receiptKeep the result unresolved and inspect the SMS log.
The outcomes table is a projection for this application; retained events remain available to investigate equal-timestamp or contradictory observations. This example does not compute billing totals from delivery events. Cost components have separate update semantics in the event reference.
Limit stored event access and retention to your application’s needs: the payload may include phone numbers, message content and metadata. Retain deduplication identities for the replay horizon you support rather than deleting them while accepting older replays.

Continue the integration