# 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](/docs/guides/webhooks#create-an-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.

```python
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](/docs/get-started/send-your-first-sms), retain its message ID, then inspect the stored result in a second terminal:

```bash
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](/docs/guides/webhooks#test-sends).

## 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](/docs/guides/sms/events).

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](/docs/guides/sms/reply-to-incoming-sms): add a separate worker to this receiver.
- [SMS events](/docs/guides/sms/events): payloads, states and failure details.
- [Webhooks](/docs/guides/webhooks): signing-secret rotation, retries and replay.
- [SMS resources](/sms/resources) and [provider migrations](/docs/guides/sms/migrate).

## Related resources

- [Sending your first SMS](/learn/sms/sending-your-first-sms) (video)
- [What does SMS mean?](/explained/sms/what-does-sms-mean) (answer)
- [SMS](/sms) (product)
- [Build your first integration](/learn/paths/integration) (course)

[Get an implementation brief](/learn/workspace?topic=sms)
