Create the Form before you have an account
One command, no signup: a real Form id and submit URL that carry into production, inside a 24-hour sandbox — five test messages stored, nothing sent. The token prints once. Save 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
SvelteKit's own answer to forms is an action in +page.server.js — which is exactly the file adapter-static can't have. This component skips the question: plain reactive lets, one fetch, and the on:submit modifier syntax that compiles on Svelte 4 and 5 alike.
<script>
let email = "";
let message = "";
let status = "idle";
async function handleSubmit() {
status = "sending";
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message }),
});
status = res.ok ? "sent" : "error";
} catch {
status = "error";
}
}
</script>
{#if status === "sent"}
<p role="status">Thanks! Your message has been sent.</p>
{:else}
<form on:submit|preventDefault={handleSubmit}>
<label>
Email
<input type="email" bind:value={email} required />
</label>
<label>
Message
<textarea bind:value={message} required></textarea>
</label>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{#if status === "error"}
<p role="status">Something went wrong. Please try again.</p>
{/if}
</form>
{/if} <script>
let email = "";
let message = "";
let status = "idle";
async function handleSubmit() {
status = "sending";
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, message }),
});
status = res.ok ? "sent" : "error";
} catch {
status = "error";
}
}
</script>
{#if status === "sent"}
<p role="status">Thanks! Your message has been sent.</p>
{:else}
<form on:submit|preventDefault={handleSubmit}>
<label>
Email
<input type="email" bind:value={email} required />
</label>
<label>
Message
<textarea bind:value={message} required></textarea>
</label>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{#if status === "error"}
<p role="status">Something went wrong. Please try again.</p>
{/if}
</form>
{/if} <script>
import ContactForm from "$lib/ContactForm.svelte";
</script>
<ContactForm /> <script> import ContactForm from "$lib/ContactForm.svelte"; </script> <ContactForm />
Send one and watch it land
Run the dev server and send yourself something, or test from the terminal. Then read the sandbox back — the message is stored, timestamped, and hasn't gone anywhere. Which, before you've claimed anything, is exactly right.
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
Claim it, point it at your inbox, route the Form. New messages are saved the moment they arrive and sent right after — retry included, at no extra thought.
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"