Skip to content

A form backend that routes.

Point an HTML form at a Postbag URL. Submissions are stored first, then delivered to email, Telegram and webhooks by your rules.

POST postbag.dev/s/fm_73c74vjq6z24

Sent with _test: true. Stored, not delivered anywhere.

This is a real form on a real Postbag account. Rate-limited like every form.

That is the whole integration.

One URL per form. It accepts application/x-www-form-urlencoded, multipart/form-data and JSON, with or without JavaScript. Plain HTML posts get a 303 back to your page.

  • No SDK required. A submit URL is the contract.
  • Control fields start with an underscore: _redirect, _gotcha, _test, _idempotency.
  • Snippets for every framework come back from the API itself, rendered for your form's fields.
Submit endpoint reference
index.html
<form action="https://postbag.dev/s/fm_8f3kq2" method="POST">
  <label>Email<input type="email" name="email" required /></label>
  <label>Message<textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>

What happens to a submission

The database makes it correct; events make it fast. If every event stream died, every submission would still arrive. Late, never lost.

submission sb_4d2k91 incoming

POST /s/fm_8f3kq2 · application/json · 412 B

{ "email": "[email protected]",
  "message": "Two machines for the Solna office?",
  "_gotcha": "" }
statusreceived spamscore 0 · honeypot clean schemav3 · no drift idempotencyunique (form, key) ✓

deliveries planned in the same transaction

  • dl_a91x02 → email · [email protected] pending Sent
  • dl_a91x03 → webhook · crm.dekhval.com pending Sent
  • dl_a91x04 → telegram · sales chat pending Sent

worker

  1. NOTIFY postbag_deliveries · claimed 3 (skip locked)
  2. dl_a91x02 email → 200 · 412 ms
  3. dl_a91x03 webhook → 503 · retry in 30 s ± 20%
  4. dl_a91x04 telegram → 200 · 188 ms
  5. dl_a91x03 webhook → 200 · attempt 2 · 964 ms
  1. It arrives.

    POST /s/{form} takes urlencoded, multipart or JSON, up to 256 KB. Honeypot, origin allowlist, per-form rate limit and Turnstile all run first, and every outcome is stored with a status. Spam is a label, not a bin.

    How spam is handled
  2. It is a row before it is anything else.

    One transaction inserts the submission, plans one delivery per matching route, and writes the event. The HTTP 200 is your receipt. Nothing in the write path talks to a third party.

    Why the database makes it correct
  3. The outbox drains.

    A worker claims pending deliveries with SELECT … FOR UPDATE SKIP LOCKED, wakes on NOTIFY and on a 15-second tick regardless. Failures back off as min(2^attempts × 30 s ± jitter, 6 h), then go dead and loud. Many workers are safe by construction.

    The outbox, explained
  4. Every attempt is on the record.

    Each delivery keeps its payload snapshot, attempts, last error and the provider's response. Retry from the dashboard or the API. Submissions are only ever deleted by you or by your retention policy.

    Deliveries in the API

Everything a human can do in the dashboard, an agent can do with an API key.

Agent-native is a property, not a feature. The test: a coding agent holding only a key goes from a fresh site repo to a working, verified, routed form, without a browser and without a human.

one conversation, four calls
$ curl https://postbag.dev/v1/me -H "Authorization: Bearer pb_live_…"
{ "organization": { "id": "org_3yv5z3", "slug": "acme", "plan": "free", "timezone": "Europe/Stockholm" },
  "key": { "prefix": "pb_live_", "scopes": ["manage"] },
  "counts": { "projects": 1, "forms": 0, "streams": 0, "destinations": 0, "routes": 0 },
  "limits": { "forms": 5, "submissions_per_month": 1000, "used": { "forms": 0, "submissions_this_month": 0 } } }

$ curl -X POST https://postbag.dev/v1/quickstart -H "Authorization: Bearer pb_live_…" \
  -d '{ "name": "Portfolio contact", "notify_email": "[email protected]", "project": "portfolio" }'
{ "form": { "id": "fm_8f3kq2", "submit_url": "https://postbag.dev/s/fm_8f3kq2" },
  "embed": { "html": "<form action=…>", "fetch": "…", "react": "…", "astro": "…", "nextjs_action": "…" },
  "verify": { "curl": "curl -X POST … -d '{\"_test\":true,…}'", "then": "GET /v1/forms/fm_8f3kq2/submissions?limit=1" },
  "next": [ { "why": "Add Telegram", "call": "POST /v1/destinations", "body": { "type": "telegram", } } ] }

$ curl -X POST https://postbag.dev/s/fm_8f3kq2 -d '{"email":"[email protected]","message":"hi","_test":true}'
{ "ok": true, "submission_id": "sb_4d2k91", "status": "received", "deliveries": ["dl_a91x02"] }

$ curl https://postbag.dev/v1/deliveries/dl_a91x02 -H "Authorization: Bearer pb_live_…"
{ "id": "dl_a91x02", "status": "sent", "attempts": 1, "response": { "status": 200, "latency_ms": 412 } }
Discoverable
GET /llms.txt and GET /openapi.json describe the whole surface. GET /v1/me says who you are, what you can do, and what already exists.
One call to a working form
POST /v1/quickstart creates the project, form, email destination and route, idempotently, and returns embed snippets plus a verification recipe.
Verifiable without a browser
Submit with _test: true and get back submission and delivery ids to poll. POST /v1/destinations/{id}/test returns the provider's response inline.
Errors that teach
Every error is { code, message, hint, docs }. Every create returns next[]: follow-up calls with ready-to-send bodies.
Safe to retry
Idempotency-Key on every POST under /v1. Creates accept if_exists: "return", so a re-run setup script gets the same objects back.
Self-describing ids
fm_, sb_, st_, ds_, rt_, dl_, prj_. An id in a log or a chat tells you what it is.

Fifteen sites. One shape. One partner.

A stream groups forms, by id or by tag, and gives them one versioned output schema. Each form carries a mapping from its own field names onto that shape. A route sends the stream to a destination, with a delivery window, a digest schedule, or both.

form · kontorsautomat-contact

  • fullName
  • Företag
  • tel
  • message

form · tcg-automat-contact

  • name
  • company
  • phone
  • msg

form · snackbar-lead

  • contact
  • org
  • mobile
  • body
  • city

…and twelve more, attached by tag:vending.

stream · vending-leads · schema v3

  • namefrom
  • companyfrom
  • phonefrom
  • messagefrom
  • siteconst

Unmapped form fields ride along under extras. An incomplete mapping is a 422 at attach time, never a surprise at delivery time.

route · instant

→ partner webhook

window 2026-09-01 → 2026-12-31
exclude spam · exclude quarantined

route · digest

→ ops@ email, daily

cron 0 8 * * * · Europe/Stockholm
one delivery per period, unique by (route, period)

route · instant

→ CRM webhook, signed

Postbag-Signature: t=…,v1=…

Changing the stream schema is a deliberate act: it creates a new immutable version, emits stream.schema.changed, and re-validates every source mapping. Downstream systems subscribe; they are never surprised.

Routing, streams and mappings in depth

Correctness is carried by unique constraints, not by application logic.

These are the invariants Postgres enforces. Everything above them, the worker, the API, the dashboard, can crash mid-flight and the data still means what it says.

Never lose a submission
Spam, schema violations, rate limits, origin rejects and over-quota all store with a status. Deletion is only by your action or your retention policy.
Contract first
The OpenAPI document is the truth. Dashboard, SDK and agents are clients of the same /v1. There is no UI-only capability.
Schemas are versions
Form and stream schemas are immutable rows. Submissions and deliveries record the version they were validated against.
Self-host parity
One image, one Postgres, one compose file. A feature that needs a cloud-only service with no self-host path does not ship.
postgres · invariants
  1. unique (form_id, idempotency_key) A retried POST never creates a second submission.
  2. unique (submission_id, route_id) One delivery per submission per route. No double sends.
  3. unique (route_id, period_key) One digest per period, however many workers run.
  4. unique (form_id, version) · rows never updated Schemas are immutable versions. Publish v2; v1 stays.
  5. organization_id not null · on every tenant row Every repository call is org-scoped. No cross-tenant query, ever.

Three destinations today. One of them is every other system.

Destinations are org-level and reusable across routes. Each can be tested with a sample payload from the API, which is how an agent verifies a wire before trusting it.

  • Email

    Sent through Resend with Reply-To set from the submission, so replying just works.

  • Telegram

    A bot message to any chat, rendered from a template.

  • Signed webhook

    JSON POST with an HMAC-SHA256 signature, timestamp and delivery id. The universal extension point.

  • Slack and Discord are next. Native CRM destinations follow only once webhooks have shown the pattern; each native adapter is maintenance forever. Destinations in detail
what a webhook destination receives
POST https://crm.example.com/postbag
Content-Type: application/json
Postbag-Signature: t=1724200000,v1=<hex hmac-sha256(secret, "{t}.{body}")>
Postbag-Delivery: dl_a91x02
Postbag-Event: submission.received

{ "id": "dl_a91x02", "type": "submission.received", "schema_version": 3,
  "stream": { "id": "st_vending", "slug": "vending-leads" },
  "form":   { "id": "fm_8f3kq2", "slug": "kontorsautomat-contact" },
  "data":   { "name": "Maja Lind", "company": "Kontorsautomat AB",  },
  "extras": { "city": "Solna" }, "meta": { "received_at": "…", "country": "SE" } }

2xx means sent. 410 means the destination disabled itself. Anything else retries with backoff, then goes dead and raises delivery.dead. Verify a signature

Self-hostable by design. The hosted product is the same image.

One container, one Postgres, one compose file. api, worker and all are entrypoints of the same image. Migrations run on boot if you ask. Signups can be disabled for a single-org install.

  • Multi-arch image: arm64 and amd64.
  • Row-level security policies ship in the migrations as a second fence.
  • Health at /health: database, worker heartbeat, oldest pending delivery age.
Self-host guide
docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment: { POSTGRES_DB: postbag, POSTGRES_USER: postbag, POSTGRES_PASSWORD: change-me }
    volumes: [ "postbag-postgres:/var/lib/postgresql/data" ]
  postbag:
    build: .            # one image: api + worker, POSTBAG_ROLE=all
    depends_on: [ db ]
    environment:
      DATABASE_URL: postgres://postbag:change-me@db:5432/postbag
      APP_URL: https://forms.example.com
      BETTER_AUTH_SECRET: change-me-too
      POSTBAG_ROLE: all
      MIGRATE_ON_BOOT: "true"
      RESEND_API_KEY: re_…
      MAIL_FROM: "Forms <[email protected]>"
    ports: [ "3000:3000" ]
volumes: { postbag-postgres: {} }

Questions, answered

What is Postbag?
Postbag is a form backend that routes. You point an HTML form (or a fetch call) at a Postbag submit URL; Postbag stores every submission durably and then delivers it to email, Telegram and signed webhooks according to routes you configure. It is multi-tenant, self-hostable, and agent-native: everything in the dashboard is available through the same /v1 API.
What is a form backend?
A form backend is a service that receives HTML form submissions so a website does not need its own server code. The site's <form action> points at the backend, which stores the data and notifies you. Postbag adds routing on top: grouping many forms into one stream, mapping fields onto one schema, and delivering to several destinations with rules.
Do I need JavaScript or an SDK?
No. A plain HTML form posting to your submit URL works; Postbag answers with a 303 redirect to your page. JSON and fetch work too. The API returns ready-made snippets for HTML, fetch, React, Astro and Next.js server actions for each form.
Does Postbag ever drop submissions?
No. Spam, schema violations, rate-limit overflow, rejected origins and over-quota submissions are all stored with a status (received, quarantined or spam) and are visible in the inbox and the API. Data is only deleted by your action or your retention policy.
How is Postbag different from Formspree, Formspark or Getform?
Three things: every submission is a durable row with an attempt-tracked delivery outbox; streams let many forms with different field names map onto one versioned schema and go to one partner or CRM; and the whole product is driven by one API with llms.txt, OpenAPI, a one-call quickstart and test submissions, so AI agents can set it up end to end. It also self-hosts as one container plus Postgres. See the comparison pages.
Which destinations are supported?
Email (via Resend, with Reply-To set from the submission), Telegram bot messages, and webhooks signed with HMAC-SHA256. Webhooks are the universal extension point; Slack and Discord are planned next.
Can AI agents like Claude Code or Cursor use Postbag?
Yes. An agent with only an API key can read GET /llms.txt and /openapi.json, call GET /v1/me, create a working routed form with POST /v1/quickstart, post a test submission with _test: true, and poll the resulting delivery ids to confirm it was sent. Every error carries a hint and a docs link; every create returns next steps.
Can I self-host Postbag?
Yes. Postbag runs as one Docker image (api, worker or both) against one Postgres database, with a docker-compose file. The hosted product is the same image with billing turned on; no feature depends on a cloud-only service without a self-host path.
How much does it cost?
The free plan includes 5 forms, 1,000 submissions a month, 5 destinations and 90-day retention. Pro is $15 a month ($12 billed yearly) and Team is $49 a month ($39 billed yearly); both are priced but not yet on sale while billing is finished. See pricing for the limits. Self-hosting is free and open source.

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.