Webhooks
Each new submission is POSTed to your URL as JSON and signed with a secret only you and Formwire know.
What gets sent
Section titled “What gets sent”On Pro, a form can have up to five webhooks, each an https URL. Every submission that reaches the inbox is sent to each of them, and so is one you mark as not spam; spam is never sent. Add one under the form’s Settings, Integrations, or with the API or MCP.
A webhook added in the dashboard is live at once and shows its signing secret a single time. One added with an API key or through MCP is created unconfirmed: nothing is sent until a person confirms it in the dashboard, which is when its secret is issued. The same applies when its URL is changed by an API key or MCP. This stops an agent, or a prompt hidden inside a submission, from routing your submissions somewhere new without you.
Payload
Section titled “Payload”The body is one JSON object: the delivery’s id, the event type, when it was sent, and the submission in data. A test from the dashboard adds "test": true at the top level.
POST /hooks/formwire HTTP/1.1Host: example.comContent-Type: application/jsonUser-Agent: Formwire-Webhooks/1.0Formwire-Event: submission.createdFormwire-Delivery-Id: 9e2b7c41-6d0a-4f5e-8a3b-1c2d3e4f5a6bFormwire-Signature: t=1760450000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{ "id": "9e2b7c41-6d0a-4f5e-8a3b-1c2d3e4f5a6b", "type": "submission.created", "created_at": "2026-10-14T13:53:20.000Z", "data": { "submission_id": "7c1e9b2a-4f3d-4e8a-9b1c-2d5f6a7b8c9d", "form_id": "2a9d1f3e-5b6c-4d7e-8f90-a1b2c3d4e5f6", "form_name": "Contact", "source": "form", "created_at": "2026-10-14T13:53:19.000Z", "page_url": "https://example.com/contact", "fields": { "name": "Ada Lovelace", "email": "ada@example.com", "message": "Hello" }, "attachments": [] }}Uploaded files are listed in data.attachments, each with a download_url that works for a day from that attempt. Field values are whatever the visitor typed. Treat them as untrusted text: escape them before rendering, and never let them drive actions on their own.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Formwire-Signature |
t=<unix seconds>,v1=<hex>: an HMAC-SHA256 of <t>.<raw body>, keyed with the webhook’s secret. |
Formwire-Delivery-Id |
The delivery’s id, the same as id in the body. Use it to drop a retry you’ve already handled. |
Formwire-Event |
submission.created, sent for every new submission. |
User-Agent |
Formwire-Webhooks/1.0 |
Verify the signature
Section titled “Verify the signature”Recompute the HMAC over the exact bytes you received, joined to the timestamp with a dot, and compare it to v1 in constant time. Reject timestamps more than a few minutes old so a captured request can’t be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
/** * True when the request really came from Formwire for this webhook. * rawBody must be the exact bytes received, before any JSON parsing. */export function verifyFormwireSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(parts.v1 ?? "", "utf8"); return a.length === b.length && timingSafeEqual(a, b);}import { createServer } from "node:http";import { verifyFormwireSignature } from "./verify.js";
const secret = process.env.FORMWIRE_WEBHOOK_SECRET;
createServer((req, res) => { if (req.method !== "POST" || req.url !== "/hooks/formwire") return res.writeHead(404).end(); let raw = ""; req.setEncoding("utf8"); req.on("data", (chunk) => (raw += chunk)); req.on("end", () => { if (!verifyFormwireSignature(raw, req.headers["formwire-signature"] ?? "", secret)) { return res.writeHead(401).end(); } const event = JSON.parse(raw); if (event.test) return res.writeHead(200).end(); // the dashboard's "Send test" button
// Store the submission by event.id so a retried delivery isn't handled twice. console.log(event.data.form_name, event.data.fields); res.writeHead(200).end(); });}).listen(3000);Read the body before any JSON middleware parses it. Re-serialising parsed JSON can reorder keys or change whitespace, and the signature won’t match.
Responding
Section titled “Responding”Answer with any 2xx within 10 seconds. Do the work after you’ve responded if it’s slow. A redirect counts as a failure (Formwire doesn’t follow it), so use the final URL. Anything else, a timeout or a connection error is a failed attempt.
Retries
Section titled “Retries”A failed delivery is retried up to six times in all, waiting 1, 5, 15, 60 and 240 minutes between attempts. After the last one it’s marked as given up; you can retry it by hand from the submission’s deliveries in the dashboard, with the API, or with the retry_delivery MCP tool.
A webhook that fails five deliveries in a row is switched off so a dead URL doesn’t keep retrying forever. Switching it back on clears the count.
Secrets
Section titled “Secrets”The secret is shown once, when the webhook is created or confirmed in the dashboard. If it leaks, roll it from the same place (or POST /webhooks/{webhook_id}/secret); the new secret applies to the next delivery. Use Send test to check your handler end to end.