Skip to content
FormSubmit
Engineering

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.

FormSubmit Team

6 min read

Diagram of a form webhook sending signed JSON from a website form to a server

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):

json
{
  "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:

HeaderExamplePurpose
x-formsubmit-eventsubmission.created or testLets you route or ignore event types
x-formsubmit-signaturet=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:

  • t is a Unix timestamp (seconds) set when the request was signed.
  • v1 is the hex-encoded HMAC-SHA256(secret, t + "." + rawBody).

Three details matter more than people expect:

  1. 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.
  2. Compare in constant time. A plain === can leak timing information. Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python.
  3. 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

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

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", 200

Store 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:

js
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.
  1. Create the catch hook in your automation tool and copy its URL.
  2. Paste the URL into your form's webhook integration settings.
  3. Send a test event so the tool can learn the payload shape.
  4. Map fields like submission.data.email into the next steps (CRM, spreadsheet, chat).
  5. 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 localhost is 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 .

Frequently asked questions

What is a form webhook?

A form webhook is an HTTP POST that a form backend sends to a URL you choose every time someone submits your form. The request body contains the submission data as JSON, so your server or automation tool can process it immediately.

How do I verify a webhook signature?

Recompute an HMAC-SHA256 of the timestamp and the raw request body using your signing secret, then compare it to the signature header with a constant-time comparison. Also reject requests whose timestamp is too old.

Can I use a form webhook with Zapier, Make or n8n?

Yes. Create a Catch Hook or Webhook trigger in the automation tool, copy its URL, and paste it as the webhook destination in your form backend. Each submission then starts your workflow.

What happens if my webhook endpoint is down?

Most form backends retry failed deliveries. FormSubmit retries after roughly 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, up to 6 attempts, and treats any 2xx response as success.

Related resources

Keep reading

Your form backend is 60 seconds away

Sign up with Google or email, create a form, paste the endpoint. Free forever for small sites — no credit card.