# Recipe — HMAC webhook verification with `mgf.fastapi.webhooks`

> **Audience:** consumers receiving webhooks from Svix, Clerk,
> or any provider using a Svix-flavoured signing scheme.
> **Optional extra:** `pip install 'mgf-common[fastapi]'` for the
> `verify_request` adapter. The `HmacWebhookVerifier` core is
> stdlib-only — works without FastAPI for CLI / batch / non-FastAPI
> webhook receivers.

This recipe pairs with **PAPER-26** (closed in v0.26.0). The pattern
is: one verifier per webhook secret, reused across requests; each
request gets verified before its body is parsed; failure raises a
typed exception that maps to 401 automatically via the HTTP-01
hierarchy.

---

## At a glance

```python
from fastapi import FastAPI, Request
from mgf.fastapi import ExceptionTranslationMiddleware
from mgf.fastapi.webhooks import HmacWebhookVerifier, verify_request

app = FastAPI()
app.add_middleware(ExceptionTranslationMiddleware)

verifier = HmacWebhookVerifier(secret=settings.webhook_secret)

@app.post("/webhooks/clerk")
async def clerk_webhook(request: Request) -> dict:
    event_id, ts = await verify_request(request, verifier)
    payload = await request.json()
    # ... process the (now-trusted) payload ...
    return {"ok": True}
```

That's the full integration. `HmacVerificationError` (raised on any
verification failure) inherits `HttpUnauthorized`, so PAPER-24's
`http_status` resolution maps it to **401** without an explicit
`status_map` entry.

---

## What the verifier checks

For each webhook request:

1. **All three headers present.** Missing any of `svix-id` /
   `svix-timestamp` / `svix-signature` raises immediately.
2. **Header lookup is case-insensitive.** Reverse proxies and ASGI
   frameworks normalise differently; the verifier handles all cases.
3. **Timestamp is parseable as an integer.** Non-numeric values raise.
4. **Timestamp is within the replay window** (default 5 minutes,
   configurable). Both stale (past) AND skewed-future timestamps are
   rejected — a clock that's drifted way ahead is as suspicious as a
   stale one.
5. **HMAC-SHA256 over `<id>.<ts>.<body>`** matches the secret-keyed
   digest, base64-encoded, against the `v1,…` portion of the
   signature header. Multi-version sigs (space-separated) are
   accepted if **any** candidate matches — supports key rotation.
6. **Constant-time comparison** via `hmac.compare_digest` — no
   timing-oracle leak.

On success, returns `(event_id, timestamp)` — useful for logging or
event-id-based de-dupe in your handler.

---

## When `verify_request` is the right adapter

`verify_request` is for **FastAPI route handlers** that take a raw
`Request` and want to read both headers + body. It MUST be called
before any code that consumes the body (`request.json()`,
`request.form()`, etc.) — once the body stream is drained, it's
empty.

For non-FastAPI integrations (Django views, Flask routes, batch
jobs, CLI replay tools), use the verifier core directly:

```python
# Django example
from mgf.fastapi.webhooks import HmacWebhookVerifier

verifier = HmacWebhookVerifier(secret=settings.WEBHOOK_SECRET)

@require_POST
def clerk_webhook(request):
    event_id, ts = verifier.verify(request.headers, request.body)
    payload = json.loads(request.body)
    # ...
    return JsonResponse({"ok": True})
```

The verifier is **transport-agnostic** by design. It takes
`Mapping[str, str]` and `bytes`; any framework that exposes those
two shapes works.

---

## Key rotation

Svix sends multi-version signatures during key rotation:

```
svix-signature: v1,old_secret_sig v1,new_secret_sig
```

The verifier accepts the request if **either** signature matches its
configured secret. Rotation procedure:

1. Configure the new secret in Svix; both old and new are active.
2. mgf-common verifier with **new** secret accepts incoming requests
   (signed with both — the new one matches, old one is ignored).
3. Old secret is retired in Svix.
4. mgf-common verifier with **old** secret would also still work
   throughout the window since it can also match its own
   signature. Either order of rotation is safe.

No code change needed during rotation — just update
`settings.webhook_secret` to the new value when the old one is
retired.

---

## Custom header schemas

`SVIX_SCHEMA` is the only built-in preset. For a non-Svix provider
with the same Svix-style three-header shape:

```python
from mgf.fastapi.webhooks import HmacWebhookVerifier, WebhookHeaderSchema

CUSTOM_SCHEMA = WebhookHeaderSchema(
    id_header="x-myprovider-id",
    timestamp_header="x-myprovider-timestamp",
    signature_header="x-myprovider-signature",
)

verifier = HmacWebhookVerifier(
    secret=settings.myprovider_secret,
    schema=CUSTOM_SCHEMA,
    signature_prefix="v1=",       # if the provider uses "v1=" not "v1,"
    replay_window_seconds=600,    # 10 minutes if your provider says so
)
```

Header names are **case-folded to lowercase on construction** of the
schema — pass any case, the matching is uniform.

### Providers that don't fit `WebhookHeaderSchema`

Some providers use shapes that won't fit one schema:

- **Stripe** — combined `Stripe-Signature: t=1614265330,v1=abc…`. One
  header, comma-separated key=value pairs.
- **GitHub** — `X-Hub-Signature-256: sha256=<hex>`. Hex encoding
  instead of base64; signature header carries the prefix-and-encoding
  inline.
- **Slack** — `X-Slack-Request-Timestamp` + `X-Slack-Signature` with
  `v0=<hex>` prefix. Hex encoding again.

For these, you have two options today:

1. **Pre-process the headers** in your route handler to synthesize
   Svix-shape headers, then pass to the verifier:

   ```python
   # Stripe → Svix shape adapter
   raw = request.headers["stripe-signature"]
   parts = dict(p.split("=", 1) for p in raw.split(","))
   shimmed_headers = {
       "stripe-id": request.headers["stripe-event-id"],
       "stripe-timestamp": parts["t"],
       "stripe-signature": "v1," + parts["v1"],  # base64-flavoured
       # ... but Stripe uses hex, not base64; needs more work
   }
   ```

   This works for some providers and not others — Stripe specifically
   uses hex, so the encoding doesn't translate cleanly.

2. **File a follow-up paper.** When a second consumer asks for
   Stripe / GitHub / Slack shape, the next iteration adds an
   encoding parameter (`encoding="base64" | "hex"`) and a combined-
   header schema variant. Until then, the recipe is "use your
   provider's SDK or handcraft until friction generalises."

---

## Configuration knobs

```python
HmacWebhookVerifier(
    *,
    secret: bytes | str,
    schema: WebhookHeaderSchema = SVIX_SCHEMA,
    replay_window_seconds: int = 300,
    signature_prefix: str = "v1,",
    now: Callable[[], float] | None = None,
)
```

| Parameter | When to override |
|---|---|
| `secret` | Always. Pass the `whsec_…` value from Svix's dashboard. `bytes` preferred; `str` is encoded UTF-8 as a convenience. |
| `schema` | When the provider's headers aren't Svix-named. |
| `replay_window_seconds` | When the provider's clock drifts wider than 5 minutes — rare. Tighter is fine; 30 seconds works for low-latency providers. |
| `signature_prefix` | Custom providers using `v1=` (Stripe-style) instead of `v1,`. |
| `now` | Tests only — pass `lambda: 1614265330.0` for deterministic timestamp checks. Production uses `time.time`. |

---

## Writing tests for your webhook handler

The verifier is constructed once and reused. In tests, the easiest
pattern is to give the verifier a fixed `now`, sign a fresh body
deterministically, and assert end-to-end:

```python
import base64
import hashlib
import hmac

from mgf.fastapi.webhooks import HmacWebhookVerifier

SECRET = b"whsec_test_secret"
TIMESTAMP = 1700000000
EVENT_ID = "msg_test"


def make_signature(body: bytes) -> dict[str, str]:
    """Build valid Svix headers for ``body`` against SECRET."""
    signed = f"{EVENT_ID}.{TIMESTAMP}.".encode() + body
    digest = hmac.new(SECRET, signed, hashlib.sha256).digest()
    return {
        "svix-id": EVENT_ID,
        "svix-timestamp": str(TIMESTAMP),
        "svix-signature": "v1," + base64.b64encode(digest).decode(),
    }


def test_valid_webhook(client):
    body = b'{"event":"user.created"}'
    response = client.post("/webhooks/clerk", content=body, headers=make_signature(body))
    assert response.status_code == 200
```

For the verifier itself, override `now`:

```python
verifier = HmacWebhookVerifier(secret=SECRET, now=lambda: float(TIMESTAMP))
```

---

## Cross-references

| Topic | Where |
|---|---|
| **PAPER-26** filing + retrospective | [`FEEDBACK.md`](../../FEEDBACK.md#paper-26--mgfcommonfastapiwebhookshmacwebhookverifier--generic-svix-shaped-hmac-verification-primitive) |
| v0.26.0 cutover migration story | [`docs/cutover/v0.26.0.md`](../cutover/v0.26.0.md) |
| HTTP-01 hierarchy + `ExceptionTranslationMiddleware` | [`docs/recipes/http.md`](http.md), [`docs/cutover/archive/v0.18.0.md`](../cutover/archive/v0.18.0.md) (HTTP-01 section) |
| PAPER-24 `http_status` resolution | [`docs/cutover/archive/v0.19.0.md`](../cutover/archive/v0.19.0.md#paper-24--exceptiontranslationmiddleware-reads-http_status) |
| FastAPI integration baseline | [`docs/recipes/fastapi.md`](fastapi.md) |
