Add a Webhook node
In a new n8n workflow, add a Webhook node set to POST. It offers two URLs: a test URL that only listens while you're watching, and a production URL that works once the workflow is active. Copy the production one — the test URL's habits are the classic first-day surprise.
Give it to Postbag, with a secret
Create a webhook Destination with the production URL and a secret of your choosing. The secret makes each post verifiable: Postbag signs the body, and your workflow can check the signature before trusting a byte of it. Self-hosters tend to appreciate that sentence.
form_id="fm_YOUR_FORM_ID" # Copy yours from: npx postbag forms list
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": "webhook",
"name": "n8n",
"config": { "url": "https://n8n.your-domain.com/webhook/…", "secret": "a-long-random-string" }
}')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
printf 'Destination ID: %s\n' "$destination_id" form_id="fm_YOUR_FORM_ID" # Copy yours from: npx postbag forms list
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": "webhook",
"name": "n8n",
"config": { "url": "https://n8n.your-domain.com/webhook/…", "secret": "a-long-random-string" }
}')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
printf 'Destination ID: %s\n' "$destination_id" Prove it before it matters
Activate the workflow, then send a test through the Destination. The execution list shows the delivery: your visitor's answers inside data, the envelope around it saying which Form and when, and a Postbag-Signature header for the sceptical.
curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations/"$destination_id"/test \
-H "Authorization: Bearer pb_live_…" curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations/"$destination_id"/test \ -H "Authorization: Bearer pb_live_…"
{
"id": "dl_a91x02",
"type": "submission.received",
"schema_version": 1,
"form": { "id": "fm_8f3kq2", "name": "Contact" },
"data": { "email": "[email protected]", "message": "hello" },
"extras": {},
"meta": { "received_at": "2026-09-09T12:00:00Z" }
} {
"id": "dl_a91x02",
"type": "submission.received",
"schema_version": 1,
"form": { "id": "fm_8f3kq2", "name": "Contact" },
"data": { "email": "[email protected]", "message": "hello" },
"extras": {},
"meta": { "received_at": "2026-09-09T12:00:00Z" }
} Route your Form to it
Tie the Form to the Destination and build the rest of the workflow — enrich, file, notify, reply. Every submission is stored before it's posted; if your instance is down for an upgrade, Postbag retries up to ten times and the messages simply wait, unbothered.
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" 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"