Tutorials

Form submission webhooks: payloads, signatures, and retries

October 29, 202610 min read

A form submission is rarely the end of the story. The lead needs to land in the CRM, the support request needs a ticket, the sign-up needs a row in your own database. You could poll an API every few minutes and ask "anything new?", but the cleaner answer is to have the submission come to you the moment it arrives. That's what a webhook is: an HTTP POST to a URL you control, sent for every new submission.

Receiving a webhook takes five lines of code. Receiving one safely takes a little more thought, because the request comes from the open internet, it can arrive more than once, and the sender is waiting on your response. This guide builds a receiver that handles all three.

What a form submission webhook looks like

Here's a real delivery from Formtorch, which this guide uses as the example sender. The headers:

POST /webhooks/formtorch HTTP/1.1
Content-Type: application/json
User-Agent: FormTorch-Webhooks/1.0
X-FormTorch-Event: form.submitted
X-FormTorch-Delivery: d3f456abc789
X-FormTorch-Signature: sha256=5d41402abc4b2a76b9719d911017c592...

And the body:

{
  "event": "form.submitted",
  "timestamp": "2026-10-29T14:30:00.000Z",
  "delivery_id": "d3f456abc789",
  "form": { "id": "abcde12345", "name": "Contact" },
  "submission": {
    "id": "sub_9x8y7z",
    "created_at": "2026-10-29T14:29:58.000Z",
    "data": { "name": "Jane Smith", "message": "Hello!" },
    "is_test": false,
    "is_spam": false
  }
}

Most webhook senders follow the same shape: an event type, a unique delivery ID, a timestamp, and a signature header that proves who sent it. The field names change between providers; the four jobs below don't.

The four jobs of a webhook receiver

  1. Verify that the request came from the sender, not from anyone who found your URL.
  2. Reject replays, so a captured request can't be re-sent later.
  3. Deduplicate, because senders retry, and a retry can arrive after you already handled the original.
  4. Acknowledge fast, process durably, so a slow handler doesn't trigger retries and a crash doesn't lose the event.

Here's a receiver that does all four, written for Express 5 on Node.js:

// server.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import { saveToInbox, startWorker } from "./inbox.js";

const SECRET = process.env.FORMTORCH_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000; // reject deliveries older than 5 minutes

function hasValidSignature(rawBody, header) {
  const expected =
    "sha256=" + createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const received = Buffer.from(header ?? "");
  const wanted = Buffer.from(expected);
  // timingSafeEqual throws on different lengths, so compare those first
  return received.length === wanted.length && timingSafeEqual(received, wanted);
}

const app = express();

app.post(
  "/webhooks/formtorch",
  express.raw({ type: "application/json", limit: "1mb" }), // keep the exact bytes
  async (req, res) => {
    if (
      !Buffer.isBuffer(req.body) ||
      !hasValidSignature(req.body, req.get("x-formtorch-signature"))
    ) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));

    // The timestamp is part of the signed body, so a replayed request can't
    // fake a fresh one. Retries arrive with a new timestamp, so they pass.
    const age = Math.abs(Date.now() - Date.parse(event.timestamp));
    if (!(age <= MAX_AGE_MS)) {
      return res.status(400).send("Stale delivery");
    }

    // Retries reuse delivery_id: a duplicate is acknowledged, not re-processed.
    const isNew = await saveToInbox(event);
    res.status(200).send(isNew ? "Accepted" : "Duplicate");
  }
);

startWorker();
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));

Each part maps to one of the four jobs.

Verify the signature against the raw body

The sender computes an HMAC-SHA256 of the request body using a secret only the two of you know, and sends the result in a header. You compute the same HMAC and compare. Anyone can find your URL; nobody without the secret can produce a matching signature.

The detail that breaks most first attempts is which bytes you sign. The signature covers the body exactly as it was sent. If you let express.json() parse the body and then re-serialize it with JSON.stringify, spacing or key order can differ, and a genuine request fails verification. That's why this route uses express.raw(): req.body is the original Buffer, untouched.

The comparison uses timingSafeEqual instead of ===. A plain string comparison can return as soon as it finds the first differing character, and with enough requests, those tiny timing differences can leak how much of a forged signature was correct. timingSafeEqual always takes the same time. It throws when the two buffers have different lengths, so the length check comes first.

Reject replays with the signed timestamp

A valid signature proves the request came from the sender at some point. It doesn't prove it was sent recently. Someone who captures one delivery could re-send it a week later, and the signature would still match.

The defense is a freshness check on the timestamp field. Because the timestamp sits inside the signed body, an attacker can't change it without breaking the signature. Five minutes is a common tolerance: generous enough for normal network delays, short enough to make a captured request useless. Keep your server's clock synced with NTP, or legitimate deliveries will start looking stale.

Formtorch builds a fresh timestamp for every delivery attempt, so retries of an old event still pass this check.

Deduplicate on the delivery ID

Every webhook sender retries. If your server doesn't respond in time, returns an error, or the response gets lost on the way back, the sender can't know whether you processed the event, so it sends it again. Your receiver has to treat "I've seen this before" as a normal case.

That's what delivery_id is for. It's the same on every retry of a given delivery, so the receiver records each ID it accepts and acknowledges repeats without redoing the work. A duplicate gets a 200, not an error: an error would trigger yet another retry.

One subtlety: a delivery ID identifies one delivery to one endpoint. If you point two webhook configurations at the same URL, the same submission arrives twice with two different delivery IDs. If you need "exactly one CRM record per submission" no matter how many deliveries arrive, deduplicate on submission.id plus event instead.

Acknowledge fast, process from an inbox

The sender is waiting on your response, and it won't wait forever. Formtorch, for example, gives an endpoint 30 seconds before treating the attempt as failed. If your handler calls a slow CRM API inline, a bad day on their side turns into retries and duplicates on yours.

So the route does the minimum: verify, record, respond. The real work happens afterward, from an inbox:

// inbox.js: in-memory stand-in for a database table (see below)
const inbox = new Map(); // delivery_id -> { event, status }

export async function saveToInbox(event) {
  if (inbox.has(event.delivery_id)) return false;
  inbox.set(event.delivery_id, { event, status: "pending" });
  return true;
}

async function handleEvent(event) {
  if (event.event === "form.submitted") {
    const { id, data } = event.submission;
    console.log(`Processing submission ${id}`, data); // your real work here
  }
  // form.spam and form.test arrive too if you subscribed to them
}

export function startWorker() {
  setInterval(async () => {
    for (const entry of inbox.values()) {
      if (entry.status !== "pending") continue;
      entry.status = "processing";
      try {
        await handleEvent(entry.event);
        entry.status = "done";
      } catch (err) {
        console.error(err);
        entry.status = "pending"; // try again on the next tick
      }
    }
  }, 1000);
}

The order matters. The event is saved before the 200 goes out, so once you've acknowledged a delivery, you can't lose it. If your handler fails, the event stays pending and the worker tries again; the sender never needs to know.

Warning

The in-memory Map keeps the example runnable, but it forgets everything on restart and isn't shared between server instances. In production, the inbox is a database table with the delivery ID as its primary key.

In Postgres, that's one table and one statement:

CREATE TABLE webhook_inbox (
  delivery_id text PRIMARY KEY,
  payload     jsonb NOT NULL,
  status      text NOT NULL DEFAULT 'pending',
  received_at timestamptz NOT NULL DEFAULT now()
);

-- Inserts a new delivery; a retry hits the primary key and changes nothing.
INSERT INTO webhook_inbox (delivery_id, payload)
VALUES ($1, $2)
ON CONFLICT (delivery_id) DO NOTHING;

The primary key does the deduplication atomically, even when two retries arrive at the same instant. The statement's row count tells you whether the delivery was new. This is the same "store first, act second" pattern behind reliable email notifications; A developer's guide to email notifications for form submissions applies it to the sending side.

Choose your status codes deliberately

Your response is the only thing the sender learns, so make it mean something:

SituationRespond withWhy
Event saved (new or duplicate)200Stops retries; you have it
Signature missing or wrong401Not from the sender; don't process
Timestamp too old400A replay, or a badly skewed clock
Your database is down500Let the sender retry later

Return a 2xx only once the event is safely stored. A 200 that you return before saving is a promise you can't keep if the process crashes a millisecond later.

Two Formtorch-specific behaviors are worth knowing. Every non-2xx response is retried, including 4xx responses, so a misconfigured secret shows up as a run of 401s in the delivery log rather than a single failure. And redirects aren't followed: a 301 from an http to https redirect, or a trailing-slash redirect, counts as a failed delivery. Register the exact final URL.

Test it locally

Webhook endpoints have to be reachable from the internet, and Formtorch only accepts https endpoint URLs. For local development, run the receiver and expose it through a tunneling tool, then register the tunnel's https URL.

Then exercise the paths that matter:

  1. A real delivery should return 200 and be processed once.
  2. Sending the same delivery again should return 200 and not be processed a second time. Log one real request's raw body and signature header, then replay it with curl within five minutes. (Formtorch's own retries, including the Retry button for a failed delivery in the delivery log, reuse the same delivery ID too.)
  3. A wrong secret in your environment should turn every delivery into a 401.
  4. A request you craft by hand, with the body changed after signing, should fail verification.

Formtorch's Send Test button fires a form.test event on demand, so you don't need to submit a real form for every test.

Setting up webhooks in Formtorch

Formtorch offers webhooks on the Starter and Pro plans, in two forms:

  • A workspace-wide endpoint under Settings → Webhooks. One URL receives events from every form, and you choose which events: form.submitted, form.spam, form.test, or any combination.
  • Per-form destinations under a form's Integrations tab: one per form on Starter, unlimited on Pro. These fire only for real, non-spam, non-test submissions.

Both use the same payload, the same X-FormTorch-Signature header, and the same retry policy: up to six attempts in total, with exponential backoff. The signing secret is shown once when you create an endpoint, so copy it straight into your environment variables. The signature verification reference has the same check in Python and PHP.

If you'd rather not run a receiver at all for the common destinations, Formtorch's native integrations cover several of them; How to send HTML form submissions to Google Sheets compares the script and no-script options for spreadsheets.

Wrapping up

A trustworthy webhook receiver does four things in a fixed order: verify the signature against the raw body, reject stale timestamps, record the delivery ID before acknowledging, and process from that record afterward. Get the order right and retries stop being a source of duplicates; they become the sender's way of guaranteeing you got the event.

The code for all four fits in two small files. What it buys you is a pipeline where every submission arrives exactly once, even on the days when something along the way doesn't.

Ready to receive form submissions as webhooks?

Add an endpoint to a Formtorch workspace and get signed, retried deliveries for every submission.

Related posts
How to send HTML form submissions to Google Sheets
TutorialsOctober 27, 20269 min read
Building a Node.js contact form that sends email
How to build a Node.js contact form that sends email
TutorialsSeptember 3, 202613 min read