Skip to content

Guide

Add a contact form to any HTML site

No backend, no build step, no JavaScript. A form tag, and somewhere real for it to go.

Last updated

Or skip the reading entirely.

Everything below is agent-work. Paste this to yours, then come back to a working form — you show up at the end to say "keep it."

Paste to your agent
Install the Postbag skill with `npx skills add faahim/postbag --skill postbag`, then use it to add a working contact form to this site and prove a test submission was stored.

Create the Form before you have an account

One command gives you a real Form: an id, a submit URL, and 24 hours to try it honestly. It accepts five test messages, stores them, and sends nothing anywhere — a quiet rehearsal space. Save the token it prints; that response is the only place you'll see it.

Create a Form before you have an account
sandbox_json="$(npx postbag --json sandbox create \
  --name "Contact" \
  --origin "https://example.com")"

submit_url="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.submit_url')"
form_id="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.id')"
sandbox_token="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox_token')"
claim_url="$(printf '%s\n' "$sandbox_json" | jq -er '.claim_url')"

printf 'Submit URL: %s\nForm ID: %s\nClaim URL: %s\nSandbox token: %s\nReplace YOUR_POSTBAG_SUBMIT_URL in the next snippet with the Submit URL above.\n' \
  "$submit_url" "$form_id" "$claim_url" "$sandbox_token"

Point a form tag at it

This is the whole integration. Swap the action for your submit URL and keep the odd hidden input at the bottom — it is a tripwire for spam bots, placed where no person will ever meet it.

That's it. No script tag, no library, nothing to keep updated. When someone presses Send, the browser does what browsers have done since 1995, and this time the message has somewhere to go.

The form, complete
<form action="YOUR_POSTBAG_SUBMIT_URL" method="POST">
  <label>
    Email
    <input type="email" name="email" required />
  </label>
  <label>
    Message
    <textarea name="message" required></textarea>
  </label>
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-10000px" aria-hidden="true" />
  <button type="submit">Send</button>
</form>

Send one and watch it land

Fill the form in on your page, or send a test from the terminal if you like receipts. Then ask the sandbox what it holds — you'll see your message, stored, with the time it arrived.

Send one test from your terminal
curl --fail --silent --show-error -X POST "$submit_url" \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "message": "hello from the terminal" }'
See it stored
POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status

Claim it when you're ready

The creation response included a claim link. Open it, sign in — Google, GitHub, or an emailed code — and the sandbox becomes a real Form in your own workspace. Same id, same submit URL: the page you just wired needs no edit. Your test messages come along, still marked as tests.

Turn on email

Sending unlocks after you claim: tell Postbag where your inbox is, then route the Form to it. From then on every new message is saved the moment it arrives and sent right after — and if your inbox has a bad day, the message calmly waits it out.

Connect your inbox, then route the Form to it
destination_json="$(curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d '{ "type": "email", "config": { "to": ["[email protected]"] } }')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
route_body="$(jq -n --arg form_id "$form_id" --arg destination_id "$destination_id" \
  '{ form_id: $form_id, destination_id: $destination_id }')"

curl --fail --silent --show-error -X POST https://postbag.dev/v1/routes \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d "$route_body"

The parts that bite

The thanks page
A plain browser post ends on a small hosted thanks page. To end on your own, add a hidden input named _redirect with the address you want — Postbag walks the visitor there instead.
The origin is part of the deal
A sandbox Form only accepts browser posts from the origin you gave at creation. Building locally? Create it with your dev address (say http://localhost:4321) and add your real domain after you claim. Terminal tests carry no origin, so curl always gets through.
Five tests, then it wants a decision
A sandbox holds five test messages of up to 16 KiB each, for 24 hours, and sends nothing anywhere. That is the rehearsal budget. Claiming makes it permanent; letting it expire costs nothing.
Leave the honeypot be
The hidden _gotcha input works by staying empty — bots fill it, people never see it. Keep it in the markup, keep it off-screen, and don't rename it.

Or clone the working example.

If you'd rather start from a finished page than paste the snippet, this little repo is the form above — swap in your submit URL and you're away.

Clone this working example

Questions, answered

Do I need any JavaScript at all?
No. A plain form post works with JavaScript disabled. If you later want an inline thank-you instead of a redirect, you can add a small fetch call to the same URL.
Where do messages go before I claim the form?
Nowhere — and that's the point. They are stored as test messages you can read with the sandbox token. Nothing is emailed or forwarded until you claim and connect an inbox.
Can the same form live on several pages?
Yes. Any page on the allowed origin can post to the same submit URL. After claiming you can allow more origins.
What happens to spam?
Messages that trip the honeypot are kept and labelled as spam, not binned. Your inbox stays quiet by default; the record stays yours to review.

Give the form job to your agent.

It can build, wire and test the form before you even sign up. Claim it when it's worth keeping.