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
| Observation | Application meaning |
|---|---|
| accepted | Bird has accepted the message; delivery remains pending. |
| sent | The message was handed to the carrier. |
| delivered | The carrier confirmed delivery. |
| undelivered, failed, expired, rejected | Inspect the corresponding event details before choosing a recovery action. |
| No final receipt | Keep 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
- Receive and reply to SMS: add a separate worker to this receiver.
- SMS events: payloads, states and failure details.
- Webhooks: signing-secret rotation, retries and replay.
- SMS resources and provider migrations.
Related resources
Continue with the documentation, guides and examples for this topic. Resources are in English.
Watch the guideSending your first SMSUnderstand the conceptWhat does SMS mean?Explore the capabilitySMSFollow the learning pathBuild your first integration
Try the practice and get an implementation brief