Add a Webhook integration to a form and FormSubmit sends an HTTP POST with a JSON body to your URL for every new submission. URLs must use HTTPS and resolve to a public host.
Payload
{
"event": "submission.created",
"form": { "id": "k3m9p2qabx", "name": "Website contact" },
"submission": {
"id": "r8T2kLm0Qa9zXc1V",
"createdAt": "2026-09-28T10:15:00.000Z",
"data": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello!"
},
"files": [
{
"field": "resume",
"filename": "cv.pdf",
"url": "https://formsubmit.app/api/files/…?exp=…&sig=…",
"size": 182044,
"contentType": "application/pdf"
}
]
}
}The Send test button delivers the same shape with "event": "test" and sample data.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | FormSubmit-Webhooks/1.0 |
X-FormSubmit-Event | submission.created or test |
X-FormSubmit-Signature | t=<unix seconds>,v1=<hex HMAC> |
Verifying signatures
The signature is HMAC-SHA256(secret, "{t}.{rawBody}") as lowercase hex, where secret is the integration's signing secret (auto-generated if you leave it blank). Always verify against the raw request body, and reject old timestamps to prevent replays.
import crypto from "node:crypto";
export function verifyFormSubmit(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
// Next.js route handler
export async function POST(req) {
const raw = await req.text();
if (!verifyFormSubmit(raw, req.headers.get("x-formsubmit-signature") ?? "", process.env.FORMSUBMIT_SECRET)) {
return new Response("Invalid signature", { status: 401 });
}
const payload = JSON.parse(raw);
// …handle payload.submission.data
return new Response("ok");
}import hmac, hashlib, time
def verify_formsubmit(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Responses, timeouts and retries
- Respond with any 2xx within 10 seconds. Do slow work asynchronously.
- Redirects are not followed.
- Retried: network errors, timeouts,
408,429and5xx. - Not retried: other
4xxresponses (fix your endpoint or config, then retry manually).
Failed deliveries are retried with exponential backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours — six attempts in total. Every attempt, status code and error appears in the form's delivery log, where you can also retry manually.
Deliveries are at-least-once: in rare cases you may receive the same submission twice, so de-duplicate on submission.id.
Zapier, Make and n8n
Use a "Catch Hook" (Zapier), "Custom webhook" (Make) or "Webhook" node (n8n) URL as the endpoint, then click Send test so the platform learns the fields.