Form Webhooks Explained: Payloads, Signatures and Retries
A form webhook pushes each submission to your own URL as JSON. Learn the payload, how to verify an HMAC webhook signature in Node and Python, and retries.
6 min read

A form webhook is an HTTP POST that your form backend sends to a URL you control every time someone submits a form, carrying the submission as JSON. To use one safely, verify its HMAC signature, make your handler idempotent, and return a 2xx status quickly so retries only happen when something actually failed.
Email notifications are fine when a human reads every message. Once submissions need to land in a CRM, a database, a ticketing system or a chat channel, a webhook for form submissions is the cleaner tool. This guide covers what arrives, how to check it is genuine, and how to build a handler that survives retries and outages.
What a form webhook actually sends
When a visitor submits your form, the form backend stores the submission, runs spam checks, and then fires an HTTP request at your webhook URL. Spam and rejected submissions typically never reach the webhook, so your handler only sees real entries.
A typical payload looks like this (this is the shape FormSubmit sends):
{
"event": "submission.created",
"form": { "id": "k3m9p2qabx", "name": "Contact" },
"submission": {
"id": "sub_123",
"createdAt": "2026-09-28T12:00:00.000Z",
"data": { "name": "Ada", "email": "ada@example.com", "message": "Hi!" },
"files": [
{
"field": "resume",
"filename": "cv.pdf",
"url": "https://formsubmit.app/api/files/...",
"size": 12345,
"contentType": "application/pdf"
}
]
}
}Alongside the body come two headers:
| Header | Example | Purpose |
|---|---|---|
x-formsubmit-event | submission.created or test | Lets you route or ignore event types |
x-formsubmit-signature | t=1759060800,v1=5f2b... | Timestamp plus HMAC signature for verification |
File URLs in webhook payloads are signed links that expire after 7 days, so if you need the file long term, download it inside your handler and store it yourself. See file uploads for how uploads are stored.
Why you must verify the webhook signature
Your webhook URL is just a public HTTPS endpoint. Anyone who learns it can POST fake JSON to it. An HMAC webhook signature solves that: the sender and your server share a secret, and the sender signs each request with it. If the signature you compute matches the one in the header, the request came from someone holding the secret and the body was not altered.
The scheme used here is simple:
tis a Unix timestamp (seconds) set when the request was signed.v1is the hex-encodedHMAC-SHA256(secret, t + "." + rawBody).
Three details matter more than people expect:
- Use the raw body. Sign and verify the exact bytes received. If your framework parses JSON first and you re-serialize it, whitespace or key order can change and the signature will never match.
- Compare in constant time. A plain
===can leak timing information. Usecrypto.timingSafeEqualin Node orhmac.compare_digestin Python. - Check the timestamp. Reject requests older than a few minutes. This limits replay attacks, where someone captures a valid request and sends it again later.
Verify the signature in Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.FORMSUBMIT_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
const age = Math.abs(Date.now() / 1000 - t);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/formsubmit",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("x-formsubmit-signature"))) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(raw);
// enqueue work here, then acknowledge
res.status(200).send("ok");
}
);In Next.js route handlers, read the body with await request.text() before parsing, for the same reason.
Verify the signature in Python
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["FORMSUBMIT_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/webhooks/formsubmit")
def handle():
raw = request.get_data()
if not verify(raw, request.headers.get("x-formsubmit-signature")):
abort(401)
event = json.loads(raw)
return "ok", 200Store the signing secret in an environment variable, never in source control, and rotate it if it leaks.
Retries and idempotency
Networks fail and servers restart, so a well-behaved sender retries. FormSubmit retries failed webhook deliveries after roughly 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, up to 6 attempts. Any 2xx response counts as success. Timeouts and 5xx responses are retried. Most 4xx responses (other than 408 and 429) are treated as permanent errors, because retrying a request your server rejected as invalid will not help.
That has two consequences for your handler.
Respond fast. Verify the signature, persist or enqueue the event, and return 200. Do slow work (CRM calls, PDF generation, sending emails) in a background job. A handler that takes too long may time out and trigger a retry even though it eventually succeeded.
Be idempotent. A retry can deliver the same submission twice, for example when your server processed the event but the response was lost. Use submission.id as a natural deduplication key:
const id = event.submission.id;
const inserted = await db.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id",
[id]
);
if (inserted.rowCount === 0) return res.status(200).send("duplicate");Return 200 for duplicates. Returning an error would only cause more retries.
Using a Zapier webhook form, Make or n8n
You do not need to write a server to use a form webhook. Automation platforms expose an inbound URL you can paste straight into your form backend:
- Zapier: create a Zap with the "Webhooks by Zapier" trigger and choose Catch Hook.
- Make: add a Custom webhook module as the first step of a scenario.
- n8n: add a Webhook node set to POST and use the production URL.
- Create the catch hook in your automation tool and copy its URL.
- Paste the URL into your form's webhook integration settings.
- Send a test event so the tool can learn the payload shape.
- Map fields like
submission.data.emailinto the next steps (CRM, spreadsheet, chat). - Turn the workflow on and submit the real form once to confirm.
Most no-code tools do not verify HMAC signatures out of the box. If the data is sensitive, keep the catch hook URL private or add a code step that checks the signature. For simple destinations, a dedicated integration may be easier: see Google Sheets and Slack.
Debugging a form webhook
When deliveries fail, work through this checklist:
- Is the URL public HTTPS? Private network and internal addresses are rejected, and
localhostis not reachable from the internet. Use a tunneling tool during local development. - What status did you return? Check your server logs for the response code. A 401 usually means the signature check failed.
- Signature mismatches almost always come from verifying a parsed and re-serialized body, using the wrong secret (for example a staging secret in production), or a clock that is far off.
- Look at the delivery log. FormSubmit's Deliveries tab shows each attempt with status, next retry time and the last error, plus a Retry button once you have fixed the problem.
- Duplicates in your database mean the handler is not idempotent yet.
Configuration details live in the webhooks docs.
Getting started
Webhooks are the most flexible way to connect a form to the rest of your stack. Sign each request, verify it in constant time, acknowledge quickly and deduplicate by submission ID, and you will have a pipeline that holds up under real traffic. If you want signed webhooks without running your own form server, FormSubmit includes them on every plan. Build a form in the form generator or compare plans on the pricing page.
Last updated .


