Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
856b9d0
feat: enhance webhooks
everysoftware Jul 17, 2026
16dd605
feat: rename webhook events to deliveries and update related references
everysoftware Jul 17, 2026
00b94e7
feat: remove .env.example
everysoftware Jul 17, 2026
2f644c9
feat: remove .secrets.baseline
everysoftware Jul 17, 2026
2f31b19
docs: standardize webhook headers
everysoftware Jul 20, 2026
e3d33c0
docs: plan standard webhook header cleanup
everysoftware Jul 20, 2026
3e3dd3d
chore: enforce LF for Docker files
everysoftware Jul 20, 2026
be77bcc
refactor: use standard webhook signatures only
everysoftware Jul 20, 2026
6d1ad9b
refactor: remove legacy webhook configuration
everysoftware Jul 20, 2026
0928f2d
docs: document standard webhook headers only
everysoftware Jul 20, 2026
c19c9e0
docs: design webhook receiver examples
everysoftware Jul 20, 2026
a77f8ee
docs: plan webhook receiver examples
everysoftware Jul 20, 2026
e7d2e3c
docs: add webhook quick-start example
everysoftware Jul 20, 2026
d072e93
docs: add advanced webhook receiver example
everysoftware Jul 20, 2026
4970483
docs: define webhook identifier terminology
everysoftware Jul 20, 2026
b0c79b1
docs: plan webhook identifier and example updates
everysoftware Jul 20, 2026
75535f3
refactor: name webhook endpoints explicitly
everysoftware Jul 20, 2026
5ba75c5
refactor: separate webhook and event identifiers
everysoftware Jul 20, 2026
52ea5cd
docs: simplify webhook quick-start verification
everysoftware Jul 20, 2026
d003927
docs: use webhook IDs for receiver idempotency
everysoftware Jul 20, 2026
d9da352
docs: design async SQLAlchemy webhook example
everysoftware Jul 20, 2026
22d83a3
docs: add async SQLAlchemy webhook receiver example
everysoftware Jul 20, 2026
2d0d116
docs: link webhook receiver examples
everysoftware Jul 20, 2026
f33eb80
docs: design webhook quickstart timestamp verification
everysoftware Jul 23, 2026
58cc299
docs: plan webhook quickstart timestamp verification
everysoftware Jul 23, 2026
cd52074
chore: ignore local worktrees
everysoftware Jul 23, 2026
a40fe14
feat: verify webhook quickstart timestamps
everysoftware Jul 23, 2026
b54b47e
feat: implement SQLAlchemy-based idempotency store for webhooks
everysoftware Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .codegraph/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
2 changes: 0 additions & 2 deletions .env.example

This file was deleted.

2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
docker/Dockerfile text eol=lf
docker/*.sh text eol=lf
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,6 @@ cython_debug/
.dev.env
!.env.example
certs

# Git worktrees
.worktrees/
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ repos:
hooks:
- id: detect-secrets
args: [
'--baseline', '.secrets.baseline',
'--exclude-files', 'docs',
'--exclude-files', 'migrations',
'--exclude-files', 'alembic.ini',
]
exclude: package.lock.json
stages: [ pre-commit, pre-merge-commit, manual ]
165 changes: 0 additions & 165 deletions .secrets.baseline

This file was deleted.

17 changes: 17 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,22 @@ services:
retries: 3
start_period: 40s

fastid-webhook-worker:
build:
context: .
dockerfile: docker/Dockerfile
target: dev
env_file: .env
entrypoint: ["poetry", "run", "python", "-m", "fastid.webhooks.worker"]
depends_on:
fastid-app:
condition: service_healthy
environment:
FASTID_DB_URL: ${FASTID_DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}}
FASTID_WEBHOOK_ALLOW_INSECURE_URLS: ${FASTID_WEBHOOK_ALLOW_INSECURE_URLS:-true}
volumes:
- "./fastid:/opt/fastid/fastid"
restart: unless-stopped

volumes:
pg_data:
5 changes: 5 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ services:
context: .
dockerfile: docker/Dockerfile
target: prod
fastid-webhook-worker:
build:
context: .
dockerfile: docker/Dockerfile
target: prod
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,18 @@ services:
retries: 3
start_period: 40s

fastid-webhook-worker:
env_file:
- .env
image: everysoftware/fastid:latest
entrypoint: ["python", "-m", "fastid.webhooks.worker"]
depends_on:
fastid-app:
condition: service_healthy
environment:
FASTID_DB_URL: ${FASTID_DB_URL:-postgresql+asyncpg://${POSTGRES_USER:-fastid}:${POSTGRES_PASSWORD:?database password required}@postgres:5432/${POSTGRES_DB:-fastid}}
FASTID_WEBHOOK_ALLOW_INSECURE_URLS: ${FASTID_WEBHOOK_ALLOW_INSECURE_URLS:-false}
restart: unless-stopped

volumes:
pg_data:
26 changes: 26 additions & 0 deletions docs/adr/0001-use-standard-webhook-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ADR 0001: Use Only Standard Webhook Headers

- Status: Accepted
- Date: 2026-07-20

## Context

FastID sends both a legacy configurable `X-Webhook-*` signature protocol and the fixed Standard Webhooks protocol. The
protocols use different identifiers, signed representations, and signature encodings. Consumers therefore have two
ways to authenticate the same delivery, only one of which protects the exact transmitted bytes.

## Decision

FastID will send and support only `webhook-id`, `webhook-timestamp`, and `webhook-signature`. The signature will remain a
versioned base64 HMAC-SHA256 over `webhook-id.webhook-timestamp.raw_body`.

The legacy signing functions, verification function, configurable header names, and configurable signature algorithm
will be removed.

## Consequences

- New examples and integrations have one authentication contract.
- The stable Webhook ID is unambiguously the consumer idempotency key.
- Verification authenticates the exact transmitted body.
- Existing consumers of `X-Webhook-*` must migrate to Standard Webhooks headers.
- Supporting a future signing change will require a new version in `webhook-signature`, not a second header family.
32 changes: 32 additions & 0 deletions docs/adr/0002-provide-tiered-webhook-receiver-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ADR 0002: Provide Tiered Standalone Webhook Receiver Examples

- Status: Accepted
- Date: 2026-07-20

## Context

A minimal webhook receiver is useful for first success but omits production concerns. Adding storage abstractions and
database setup to that same file would obscure the authentication flow. Making examples import shared implementation
code would reduce duplication but make each example harder to copy into another service.

## Decision

FastID will provide three standalone FastAPI receiver applications:

- a quick-start authentication example;
- an advanced in-memory Webhook-ID idempotency reference;
- an advanced SQLAlchemy Webhook-ID idempotency reference.

Each application will independently implement Standard Webhooks verification using Python's standard library. Advanced
examples will share the same conceptual claim, complete, and release lifecycle but will not import each other.
The SQLAlchemy example will use SQLAlchemy's async APIs with `aiosqlite` supplied by an optional Poetry `examples`
dependency group.

## Consequences

- New users can reach a working receiver without database setup.
- Experienced users can compare volatile and durable idempotency boundaries.
- Each file can be copied independently.
- Signature-verification code is intentionally duplicated and must be changed consistently if the protocol evolves.
- The in-memory example is not production-durable and must say so prominently.
- The SQLAlchemy example introduces database concepts only in its dedicated file.
89 changes: 89 additions & 0 deletions docs/docs/tutorial/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Webhooks

FastID sends user lifecycle events to every active endpoint subscribed to the event type. Delivery is asynchronous,
durable, and at least once: consumers must treat `webhook-id` as an idempotency key because a delivery can be repeated
after a timeout or worker crash. Delivery order is not guaranteed.

## Request format

Each request is a JSON `POST` with Standard Webhooks headers:

- `webhook-id`: Webhook ID, unchanged for retries of the same delivery.
- `webhook-timestamp`: Unix timestamp for the delivery attempt.
- `webhook-signature`: `v1,<base64 HMAC-SHA256>` signature.

The signed value is the exact byte sequence `webhook-id.webhook-timestamp.raw_body`. Verify the raw request body before
parsing JSON; parsing and serializing it again can change the bytes.

```python
from fastapi import FastAPI, Header, Request

from fastid.security.webhooks import verify_standard_headers

app = FastAPI()


@app.post("/fastid-webhooks")
async def receive(request: Request) -> dict[str, bool]:
body = await request.body()
if not verify_standard_headers(body, request.headers, "whsec_..."):
return {"accepted": False}
event = await request.json()
# Atomically record request.headers["webhook-id"] before applying side effects.
return {"accepted": True}
```

The payload contains a separate logical event ID that can be shared by deliveries to multiple endpoints:

```json
{
"event": {
"event_type": "user_registration",
"event_id": "019b...",
"timestamp": 1784293200
},
"data": {
"user": {
"id": "019b...",
"email": "person@example.com"
}
}
}
```

User events that historically exposed user fields directly in `data` continue to include those fields during the
compatibility period; use `data.user` in new consumers.

## Receiver examples

Runnable receivers are available for different integration stages:

- [`webhook_quickstart.py`](../../../examples/webhook_quickstart.py) verifies a signature and logs the event. It does
not check timestamp freshness or provide replay protection and idempotency.
- [`webhook_sqlalchemy.py`](../../../examples/webhook_advanced.py) persists atomic Webhook-ID claims with async
SQLAlchemy. Install its SQLite driver with `poetry install --with examples`.

The in-memory example is a concurrency reference, not durable storage. Use the SQLAlchemy example or another shared
persistent idempotency store before applying non-idempotent production side effects.

## Delivery behavior

A `2xx` response completes delivery. Other responses and network failures are retried with jittered exponential
backoff for roughly three days. FastID respects `Retry-After` up to 24 hours, never follows redirects, disables an
endpoint immediately after `410 Gone`, and disables it after all attempts are exhausted.

The API transaction stores delivery records; a separate worker sends them:

```console
python -m fastid.webhooks.worker
```

Docker Compose starts this worker automatically. Its Prometheus metrics are exposed on port `9101` inside the Compose
network. Delivery and attempt history is also visible in the FastID admin application.

## Endpoint security

Production endpoints must use HTTPS, contain no URL credentials, and resolve only to public IP addresses. FastID pins
the validated address for the connection and does not follow redirects. Keep the worker in an egress-restricted network
as defense in depth. Local HTTP and private-address endpoints require the explicit
`FASTID_WEBHOOK_ALLOW_INSECURE_URLS=true` development setting.
Loading
Loading