Concepts

How Postel works

The mental model in one page — the outbox, the worker pool, the delivery lifecycle, and where the receiver fits.

View as Markdown

Postel is a library, not a service. Everything below runs inside your application process(es), against your existing relational database. There is no broker, no sidecar, no second deployment — the database you already operate is the queue, the audit trail, and the coordination point.

The sending path

One send() call is a database INSERT. Everything after that is the worker pool's job:

Three properties fall out of this shape:

  • Atomicity. send() takes your transaction handle ({ tx }), so the outbox row commits or rolls back with the business write that caused it. No dual-write race, no two-phase commit — the delivery guarantees page walks through why this is the whole point.
  • Crash safety. A worker leases a message (default 60s, renewed while working) rather than deleting it. A worker that dies mid-delivery just lets its lease expire; another worker picks the row up. Nothing is lost because nothing left the database until it was delivered.
  • Horizontal scale without coordination. Multiple app instances each run a pool; FOR UPDATE SKIP LOCKED (single-writer BEGIN IMMEDIATE on SQLite) makes them race safely for rows without talking to each other.

The message lifecycle

A message's status is the outbox-level lifecycle. Each endpoint's individual delivery outcome lives on its attempts — one message fanning out to three endpoints produces three attempt chains under one message:

The dispatcher decides each attempt from the response status: 2xx is success; 408/429 are retryable and honor a Retry-After header; any other 4xx is failed-permanent (the request itself is wrong — retrying is wasted work); 5xx, timeouts, and network errors retry on the schedule. Everything is recorded — postel.outbound.messages.attempts(id) returns the full audit trail, and replay re-enqueues anything by id, endpoint + time range, or predicate.

The endpoint state machine

Endpoints protect the pool from broken receivers in two independent ways:

The circuit breaker is temporary and automatic — while open, attempts skip the HTTP call and re-enqueue, so one down endpoint can't saturate worker capacity. Auto-disable is the long-term version: an endpoint broken for long enough moves to disabled and leaves delivery rotation until a human (or reconciliation job) re-enables it. Both are per-endpoint state in the database, so they survive restarts and are shared across instances.

The receiving path

The receiver is the same library run from the other side, and it's deliberately simpler — no storage required for the basic path:

  1. A framework gate captures the exact received bytes (why that matters) and hands them to verify().
  2. verify() checks headers, timestamp window, and signature — in constant time, against every configured verifier until one matches.
  3. Optionally, dedup records the webhook-id so an at-least-once producer's retry doesn't run your handler twice.
  4. Your handler runs, with the verified, typed event.

What's next

On this page