ADR 0007. Webhook ingestion into bronze

Status: accepted. Implemented 2026-08-24 in kevinvu184/up. Complements ADR 0001 (the poll becomes the safety net) and closes a gap in ADR 0003's silver contract.

Context

Polling gave bronze a daily freshness bound and, more seriously, a blind spot: polling re-observes rows that exist but cannot observe absence, so a HELD transaction deleted upstream (a returned deposit, or Up's rare settle-as-delete-plus-create sequence) would linger in silver as a ghost. Up's webhook API delivers TRANSACTION_CREATED, TRANSACTION_SETTLED and TRANSACTION_DELETED events with retry on non-200, a shared-secret HMAC signature, and a 30 second response deadline. The facts were verified against Up's OpenAPI spec and developer.up.com.au/llms.txt before building.

Decisions

An HTTP composition root with no lock and no cursor

bronze.webhook.ts is the fourth entry point and follows the established shape minus the machinery it does not need. It has no lock, because concurrent deliveries only perform atomic single-row writes keyed by transaction id, and no progress key, because a webhook has no position. It records bronze_webhook_last_event per delivery and bronze_webhook_last_error on failure, the same key shapes as the cron jobs. It runs the migration runner first, because after a deploy the webhook may be the first entry point to fire.

The signature is the authentication

The endpoint is public by necessity, so every request is verified before anything is parsed: SHA-256 HMAC of the raw body against the X-Up-Authenticity-Signature header (hex, confirmed by Up's own examples), compared constant-time, with a 401 and no side effects on mismatch. The secret lives in UP_WEBHOOK_SECRET, captured once at webhook creation.

Fetch current state, never trust the payload

Events carry only a transaction id, so the handler fetches the transaction from the API through the existing client (which gained fetchTransaction, reusing the same backoff and deadline discipline) and writes what it fetched via the same bronze upsert the poller uses. This also dissolves ordering: retries can deliver CREATED after SETTLED, but every delivery writes the state fetched at that moment, so duplicates and reordering converge on the same row.

200 means durably accepted

The handler responds 200 only after the write commits and 500 on any failure, so Up's exponential backoff is the retry queue and the idempotent upsert makes redelivery free. Up advises deferring work to a queue before responding; this handler deliberately works synchronously instead, because one fetch plus one row write completes in seconds against a 30 second deadline, and 200-after-commit is exactly what makes the retry a real safety net. Unknown event types are recorded and answered 200, so a future Up event type can never trap the endpoint in a retry loop.

Deletions carry through both layers

Migration 8 adds deleted_at to bronze. A DELETED event marks the existing row (keeping its raw, because "Up deleted this" is a fact bronze preserves) and bumps last_seen_at so the silver read picks it up; an unknown id lands as a tombstone whose raw is the event body. The silver cron partitions each batch into transforms and deletes, and the silver write applies upserts, deletes and the watermark in one transaction, keeping silver's contract of current live transactions true. Gold needed no change at all: deletions apply only to HELD transactions and gold counts settled rows only. The rare settle-as-delete-plus-create sequence needs no special handling, because the held id leaves through the delete path and the new id arrives through the ordinary create path.

Consequences

A transaction reaches bronze within seconds of occurring and flows to silver and gold on the existing quarter-hour cadence, with the daily poll now serving as the sweep for anything delivered while the endpoint was down beyond Up's retry horizon; formally demoting that poll to a reconciler is the next increment. The one new operational duty is secret hygiene: secretKey is shown once at webhook creation, and losing it means creating a replacement webhook. Up's delivery logs help debugging but may be purged, so bronze remains the only audit trail.