Create the Form before you have an account
One command, no account: a real Form id and submit URL — the ones you'll keep — inside a 24-hour sandbox that stores up to five test messages and sends nothing anywhere. Save the token from the response; it isn't shown again.
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"
Add the component — Nuxt finds it
Put the file in components/ and it's globally available as ContactForm, no import line anywhere. The explicit ref import from vue is deliberate: Nuxt would auto-import it too, but the file stays honest about what it uses and survives being copied into a plain Vue project.
There's no server/api handler in this guide on purpose. The browser posts straight to Postbag, so your Nitro server never holds a visitor's message it could drop.
<script setup>
import { ref } from "vue";
const email = ref("");
const message = ref("");
const status = ref("idle");
async function handleSubmit() {
status.value = "sending";
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.value, message: message.value }),
});
status.value = res.ok ? "sent" : "error";
} catch {
status.value = "error";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks! Your message has been sent.</p>
<form v-else @submit.prevent="handleSubmit">
<label>
Email
<input type="email" v-model="email" required />
</label>
<label>
Message
<textarea v-model="message" required></textarea>
</label>
<button type="submit" :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending…' : 'Send' }}
</button>
<p v-if="status === 'error'" role="status">Something went wrong. Please try again.</p>
</form>
</template> <script setup>
import { ref } from "vue";
const email = ref("");
const message = ref("");
const status = ref("idle");
async function handleSubmit() {
status.value = "sending";
try {
const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.value, message: message.value }),
});
status.value = res.ok ? "sent" : "error";
} catch {
status.value = "error";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks! Your message has been sent.</p>
<form v-else @submit.prevent="handleSubmit">
<label>
Email
<input type="email" v-model="email" required />
</label>
<label>
Message
<textarea v-model="message" required></textarea>
</label>
<button type="submit" :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending…' : 'Send' }}
</button>
<p v-if="status === 'error'" role="status">Something went wrong. Please try again.</p>
</form>
</template> Send one and watch it land
Use the form in dev, or send from the terminal. Then read the sandbox back: your message is stored with its arrival time, which is the receipt that matters.
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, connect your inbox, route the Form to it. Every new message is saved on arrival and sent right after — and a flaky receiving inbox becomes Postbag's problem to retry, not your visitor's problem to notice.
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"