Architecture: the submit path, the outbox and the worker
How Postbag runs: one Hono server with a public submit path and the /v1 API, a worker that drains a Postgres outbox with SELECT … FOR UPDATE SKIP LOCKED, LISTEN/NOTIFY plus a 15-second tick, exponential backoff, digests, drift inference, retention, and multi-tenancy.
Updated · Markdown
One TypeScript monorepo, one Docker image, one Postgres. api, worker and all are entrypoints of the same image. Postgres is the only stateful dependency.
HTML form / fetch ──► POST /s/{form} (public)
agent / CLI / MCP ──► /v1/* (keyed)
dashboard ──► /app/* (SPA, cookie auth)
marketing/docs ──► / (static)
│
worker: drains deliveries, digests, inference, retention
│
Postgres (truth) ──► Resend, Telegram, webhooks
The submit path
Resolve form → parse → cheap checks that store anyway → schema → one transaction (submission + one delivery per route + event) → respond → NOTIFY. No third-party call in the write path; Turnstile is the bounded exception. Details in the submit endpoint reference.
The worker
- Claims with
SELECT … FOR UPDATE SKIP LOCKEDondeliveries WHERE status IN ('pending','failed') AND next_attempt_at <= now(). Multiple workers are safe by construction. - Applies the destination adapter, records
response, moves status. Backoffmin(2^attempts × 30 s ± 20 %, 6 h);deadafter 8 attempts (email, Telegram) or 10 (webhook).deadraisesdelivery.deadand a dashboard alert; storms are throttled per destination. - Digest loop once a minute: for every (route, period) whose period has closed, one delivery per destination, keyed by the unique
(route_id, period_key). - Housekeeping: schema inference for
observeforms, retention deletion, destination health. - Wakes on
LISTEN postbag_deliveriesand on a 15 s tick regardless. Realtime is an accelerator, never the transport.
Destination adapters
interface DestinationAdapter<C> {
type: string
configSchema: ZodType<C> // validates and documents config
redactConfig(c: C): Partial<C> // what the API may echo back
test(c: C, sample: Payload): Promise<Result>
deliver(c: C, payload: Payload, ctx: DeliveryContext): Promise<Result>
}
Adding a destination type is adding one file implementing this. Webhook is the reference implementation.
Multi-tenancy
organization_id on every tenant table; repositories take an organization scope and refuse to run without one. Row-level security policies and a postbag_app role ship in the migrations as a second fence. Public submit runs on a narrow, audited path. Plan limits are checked at creation (forms, destinations) and counted per month (submissions) with soft-fail: over-limit submissions are stored and flagged, delivery pauses until the plan allows.
Observability
Structured JSON logs with org_id, form_id, delivery_id. /health reports database, worker heartbeat and oldest pending delivery age. Every organization sees its own events stream: observability is a product feature.
Deliberately not doing
No Redis or external queue (the outbox is the queue). No form builder. No multi-region until p95 submit latency from target markets exceeds 300 ms.
Your first form is three minutes away.
Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.