In short: Postbag's delivery guarantee is structural: a submission is inserted in the same transaction as one delivery row per matching route, and a worker drains those deliveries with bounded retries and a loud dead state. Correctness is carried by unique constraints in Postgres, not by application logic.
The write path makes no network calls
POST /s/{form} parses the body (up to 256 KB), runs the cheap checks, validates against the schema if one is enforced, and then does one transaction: insert the submission, plan the deliveries, write the event. Then it responds. The only exception is optional Cloudflare Turnstile verification, which is bounded by a short timeout and fails open into quarantine rather than rejecting.
Every outcome is a status
Honeypot hit: stored as spam. Origin not on the allowlist: stored as quarantined with reason origin_rejected. Over the per-form rate limit: quarantined, reason rate_limited. Schema violation in enforce mode: quarantined, reason schema_violation, plus a drift event. Over your monthly quota: stored and flagged, delivery paused until the plan allows.
All of them are visible in the inbox and through GET /v1/submissions. Routes exclude spam and quarantined submissions by default, but you decide.
The outbox and the worker
Deliveries are rows with status pending, sending, sent, failed, dead or skipped. A worker claims work with SELECT … FOR UPDATE SKIP LOCKED, so several workers are safe by construction. It wakes on LISTEN/NOTIFY the instant a submission lands, and on a 15-second tick regardless: realtime is an accelerator, never the transport.
Failures retry with backoff min(2^attempts × 30 s ± jitter, 6 h). After 8 attempts for email and Telegram and 10 for webhooks a delivery goes dead, raises delivery.dead, and shows as an alert. Dead deliveries keep their payload snapshot and can be retried by hand from the dashboard or POST /v1/deliveries/{id}/retry.
select * from deliveries
where status in ('pending','failed') and next_attempt_at <= now()
order by next_attempt_at
for update skip locked
limit 50; select * from deliveries
where status in ('pending','failed') and next_attempt_at <= now()
order by next_attempt_at
for update skip locked
limit 50; Invariants the database enforces
Because these live in Postgres, a crash in the worker, the API or the dashboard cannot produce a duplicate send or a lost row. It can only produce a delay.
- submissions (form_id, idempotency_key) is unique: a retried POST never creates a second submission.
- deliveries (submission_id, route_id) is unique: one delivery per submission per route.
- digests (route_id, period_key) is unique: one digest per period.
- form_schemas and stream_schemas (owner, version) are unique and rows are never updated.
- Every tenant row carries organization_id; repositories refuse to run without an organization scope.
Retention is your policy
Data is deleted only by explicit user action or by your plan's retention period (90 days on the free plan, 365 on Pro, 730 on Team, effectively unlimited when self-hosted). Test submissions (_test: true) are excluded from quotas.