Skip to content

Never lose a submission: a durable outbox, not a best-effort send

The HTTP 200 is your receipt. Nothing in the write path talks to a third party, and nothing that goes wrong later can reach back and lose what was received.

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.

what the worker runs
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.

Questions, answered

What does Postbag return when a submission is spam?
The same 200 (or 303 for HTML posts) as any other submission. The submission is stored with status spam; routes skip it by default. Bots learn nothing.
What if my webhook endpoint is down for an hour?
Deliveries fail, back off exponentially up to six hours between attempts, and keep retrying up to 10 times. When your endpoint returns, they are sent with their original payload snapshot.
Can two workers send the same delivery twice?
No. Claims use FOR UPDATE SKIP LOCKED and the (submission, route) pair is unique, so a delivery is owned by one worker at a time.
Is there a queue like Redis or SQS?
No. The outbox table is the queue (ADR-002). It would only be revisited if Postgres contention were measured, which at form-submission volumes it is not.

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.