Lands in your inbox, and hitting Reply goes straight to the person who wrote to you.
One submit URL
Your form already knows where to go.
Point your form at one URL, done — whatever your site speaks. We never say "got it" until we have.
This is a real Postbag form, living its normal life. Go ahead, say hello.
Use what your site already uses.
Ready-to-paste snippets, generated for your own form too.
<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),
})
// → { ok: true, submission_id: "sb_4d2k91", status: "received" } await fetch("https://postbag.dev/s/fm_8f3kq2", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
// → { ok: true, submission_id: "sb_4d2k91", status: "received" } export function ContactForm() {
async function submit(event) {
event.preventDefault()
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: new FormData(event.currentTarget) })
}
return <form onSubmit={submit}>{/* email, message, send */}</form>
} export function ContactForm() {
async function submit(event) {
event.preventDefault()
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: new FormData(event.currentTarget) })
}
return <form onSubmit={submit}>{/* email, message, send */}</form>
} "use server"
export async function submitForm(formData: FormData) {
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: formData })
}
// works as a form action — no client JavaScript needed "use server"
export async function submitForm(formData: FormData) {
await fetch("https://postbag.dev/s/fm_8f3kq2", { method: "POST", body: formData })
}
// works as a form action — no client JavaScript needed 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"} 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"} - HTML
- Plain forms work exactly as they are.
- JSON
- fetch gets a receipt back, with an id to prove it.
- Redirect
- No JavaScript? Visitors land right back on your page.
It lands before it travels.
Someone fills in your form at 2am on a Saturday. Your email is mid-outage. On Monday the message is still safely in Postbag. If the retries ran out, the Delivery is marked and ready to send again once email is back.
-
HTTP 200
Received
The 2am message is kept the instant it lands. Even spam gets a label, not a trapdoor.
-
committed
Saved first
Saved means saved — before we so much as think about emailing anyone.
-
worker awake
Routed second
Sending comes second. A bad email day just means waiting, never vanishing.
-
attempt 2 sent
Kept on record
Failed sends retry a set number of times. If they run out, the Delivery stays clearly marked and ready to retry when the other side is back.
The agent can finish before you sign up.
It builds the form, wires it in, and proves messages arrive. You show up at the end to say "keep it."
-
Create
Your agent asks Postbag for a form and gets one. No account, no API key, no waiting on you.
-
Wire
The form has its permanent address from day one. The agent wires it into your site.
-
Prove
It sends a few test messages and reads them back. You get proof, not promises.
-
Claim
Like what you see? Sign in and claim it. Same form, same address — now it's yours.
-
Route
Tell it where messages should go — your inbox, Telegram, wherever — and you're live.
$ curl -X POST https://postbag.dev/v1/public/sandboxes \
-H "content-type: application/json" \
-H "Idempotency-Key: <uuidv4>" \
-d '{"name":"Contact","origin":"https://example.com"}'
{ "sandbox": { "id": "fm_…", "status": "active",
"submit_url": "https://postbag.dev/s/fm_…",
"expires_at": "2026-08-25T09:00:00Z",
"accepted_count": 0, "remaining": 5 },
"sandbox_token": "pbs_…", "claim_url": "https://postbag.dev/app/claim#token=…" }
$ curl https://postbag.dev/v1/public/sandboxes/fm_… \
-H "Authorization: Sandbox pbs_…"
{ "id": "fm_…", "status": "active", "remaining": 4,
"submissions": [{ "id": "sb_…", "received_at": "…" }] } $ curl -X POST https://postbag.dev/v1/public/sandboxes \
-H "content-type: application/json" \
-H "Idempotency-Key: <uuidv4>" \
-d '{"name":"Contact","origin":"https://example.com"}'
{ "sandbox": { "id": "fm_…", "status": "active",
"submit_url": "https://postbag.dev/s/fm_…",
"expires_at": "2026-08-25T09:00:00Z",
"accepted_count": 0, "remaining": 5 },
"sandbox_token": "pbs_…", "claim_url": "https://postbag.dev/app/claim#token=…" }
$ curl https://postbag.dev/v1/public/sandboxes/fm_… \
-H "Authorization: Sandbox pbs_…"
{ "id": "fm_…", "status": "active", "remaining": 4,
"submissions": [{ "id": "sb_…", "received_at": "…" }] } This is the whole setup, exactly as your agent sees it. The nice part: you never have to read it.
One form is simple. Fifteen can stay that way.
Fifteen sites, each naming its fields a little differently. Postbag lines them up, so everything arrives in one tidy place.
Form · office-contact
fullName, company, tel
Form · machine-enquiry
name, org, phone
Form · partner-lead
contact, business, mobile
and twelve more by tag
Stream · enquiries
Schema v3
- name
- string
- company
- string
- phone
- string
- site
- constant
If a form doesn't fit, Postbag says so up front — not three weeks later, in production, at night.
Route
partner webhook
Route
daily ops email
Route
CRM webhook
Change the shape whenever you need. Older records keep their old shape — nothing breaks quietly behind your back.
Get things the moment they arrive, or as one tidy daily digest. Spam never tags along.
Explore routingNo crossed fingers
Promises you don't have to take our word for.
Servers crash. Ours too — that's computers. Your messages are already safe when it happens.
Never lose a message
Spam, rate limits, odd traffic — everything that arrives is kept, with a label, until you delete it or its retention window ends.
No side doors
The dashboard, the CLI and your agent all go through the same front door. Anything you can click, an agent can do.
Yours means yours
Every record belongs to your workspace, fenced twice over. No one else's account can so much as glance at it.
-
unique (form_id, idempotency_key)Reusing an idempotency key cannot create a duplicate Submission.
-
unique (submission_id, route_id)Each Route gets one Delivery record per Submission.
-
unique (route_id, period_key)Each Route gets one digest record per period.
-
unique (form_id, version)Old records are never quietly rewritten.
-
organization_id not nullYour data belongs to you, and the rules know it.
Each line pairs a real database constraint with the practical promise it can actually keep.
Send it somewhere useful.
Your inbox, Telegram, your CRM — set up once, used by every form. Always testable before it matters.
Telegram
A message in your chat the moment something arrives. Lovely for the phone-first life.
Webhook
For everything else: a retried nudge to any URL you own. Add a secret when the receiver should verify it.
Webhooks reach everything else.
Add a secret and Postbag signs every attempt, so your endpoint can verify it. If your server naps, we try up to 10 times, then keep the Delivery clearly surfaced for you to retry when it wakes up.
POST https://crm.example.com/postbag
Content-Type: application/json
Postbag-Signature: t=1724200000,v1=<hmac-sha256>
Postbag-Delivery: dl_a91x02
Postbag-Event: submission.received
{ "id": "dl_a91x02", "type": "submission.received",
"schema_version": 3,
"form": { "id": "fm_8f3kq2", "slug": "office-contact" },
"data": { "name": "Maja Lind", "company": "Kontorsautomat AB" },
"meta": {} } POST https://crm.example.com/postbag
Content-Type: application/json
Postbag-Signature: t=1724200000,v1=<hmac-sha256>
Postbag-Delivery: dl_a91x02
Postbag-Event: submission.received
{ "id": "dl_a91x02", "type": "submission.received",
"schema_version": 3,
"form": { "id": "fm_8f3kq2", "slug": "office-contact" },
"data": { "name": "Maja Lind", "company": "Kontorsautomat AB" },
"meta": {} } Run it here. Or run it yourself.
Two doors into the same product — truly the same, not a generous demo. Cloud just means the server chores are ours.
postbag.dev
We keep it running.
Hosting, upgrades, backups, email plumbing — handled. Every plan gets the whole product; you pay for room, not features.
your-server.example
You keep the keys.
One container, one database, everything included. There is no smaller "community edition" — this is the edition.
- One container
- Run the whole thing, or split API and sender.
- One database
- Postgres is the only other moving part.
- Open source
- AGPL service. MIT SDK, CLI and MCP server.
Questions, answered
What is Postbag?
What is a form backend?
<form action> points at Postbag; Postbag keeps the message safe and tells you. No server code on your end.Do I need JavaScript or an SDK?
Does Postbag ever lose submissions?
How is Postbag different from Formspree, Formspark or Getform?
Where can Postbag send messages?
Can coding agents like Claude Code or Cursor use Postbag?
Can I self-host Postbag?
How much does it cost?
Give the form job to your agent.
It can build, wire and test the form before you even have an account. Claim it when it's worth keeping.