Create the Form before you have an account
One command stands in for the backend you were told you'd need. It returns a Form id and a submit URL that carry straight through to production, wrapped in a 24-hour sandbox: five test messages, safely stored, nothing sent. The printed token shows up once — keep it.
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" 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"
Write the component
One component, three pieces of state, one fetch. While the send is in flight the button politely says so; on success the form steps aside for a confirmation; on failure it says something went wrong and lets the visitor try again — their words still in the boxes, not eaten.
import { useState } from "react";
export default function ContactForm() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState("idle");
async function handleSubmit(event) {
event.preventDefault();
setStatus("sending");
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message }),
});
setStatus(res.ok ? "sent" : "error");
} catch {
setStatus("error");
}
}
if (status === "sent") {
return <p role="status">Thanks! Your message has been sent.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Message
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required></textarea>
</label>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "error" && <p role="status">Something went wrong. Please try again.</p>}
</form>
);
} import { useState } from "react";
export default function ContactForm() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState("idle");
async function handleSubmit(event) {
event.preventDefault();
setStatus("sending");
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message }),
});
setStatus(res.ok ? "sent" : "error");
} catch {
setStatus("error");
}
}
if (status === "sent") {
return <p role="status">Thanks! Your message has been sent.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Message
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required></textarea>
</label>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "error" && <p role="status">Something went wrong. Please try again.</p>}
</form>
);
} Send one and watch it land
Render it, type something kind to yourself, press Send. Or test from the terminal. Either way, ask the sandbox what it holds: your message, stored with a timestamp, before anything else got a say.
curl --fail --silent --show-error -X POST "$submit_url" \
-H "content-type: application/json" \
-d '{ "email": "[email protected]", "message": "hello from the terminal" }' curl --fail --silent --show-error -X POST "$submit_url" \
-H "content-type: application/json" \
-d '{ "email": "[email protected]", "message": "hello from the terminal" }' POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status 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
Once claimed, connect your inbox and route the Form to it. New messages are saved first and sent second, which is the order you want when your email provider picks a bad afternoon.
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" 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"