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.
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.
<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> <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>
await fetch("https://postbag.dev/s/fm_8f3kq2", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}) await fetch("https://postbag.dev/s/fm_8f3kq2", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}) async function handleSubmit(event) {
event.preventDefault()
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: new FormData(event.currentTarget) })
}
export function ContactForm() {
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</form>
)
} async function handleSubmit(event) {
event.preventDefault()
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: new FormData(event.currentTarget) })
}
export function ContactForm() {
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</form>
)
} "use server"
export async function submitForm(formData: FormData) {
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: formData })
} "use server"
export async function submitForm(formData: FormData) {
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: formData })
} curl -X POST https://postbag.dev/s/fm_8f3kq2 \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","message":"Hello","_test":true}'
# → {"ok":true,"submission_id":"sb_4d2k91","status":"received","deliveries":["dl_…"]} curl -X POST https://postbag.dev/s/fm_8f3kq2 \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","message":"Hello","_test":true}'
# → {"ok":true,"submission_id":"sb_4d2k91","status":"received","deliveries":["dl_…"]} 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.
POST /s/fm_8f3kq2 · application/json · 412 B
{ "email": "[email protected]",
"message": "Two machines for the Solna office?",
"_gotcha": "" } deliveries planned in the same transaction
- dl_a91x02 → email · [email protected] pending
- dl_a91x03 → webhook · crm.dekhval.com pending
- dl_a91x04 → telegram · sales chat pending
worker
- NOTIFY postbag_deliveries · claimed 3 (skip locked)
- dl_a91x02 email → 200 · 412 ms
- dl_a91x03 webhook → 503 · retry in 30 s ± 20%
- dl_a91x04 telegram → 200 · 188 ms
- dl_a91x03 webhook → 200 · attempt 2 · 964 ms
-
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 -
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 -
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 -
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.
$ 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 } } $ 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.
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.
-
unique (form_id, idempotency_key)A retried POST never creates a second submission. -
unique (submission_id, route_id)One delivery per submission per route. No double sends. -
unique (route_id, period_key)One digest per period, however many workers run. -
unique (form_id, version) · rows never updatedSchemas are immutable versions. Publish v2; v1 stays. -
organization_id not null · on every tenant rowEvery 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
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" } } 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.
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: {} } 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?
What is a form backend?
<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?
Does Postbag ever drop submissions?
How is Postbag different from Formspree, Formspark or Getform?
Which destinations are supported?
Can AI agents like Claude Code or Cursor use Postbag?
Can I self-host Postbag?
How much does it cost?
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.