Reference
One HMAC over the timestamp and the raw body, and nothing else.
x-freelyform-signature carries t= and v1=. The signed string is the timestamp, a dot, then the raw body — verify before you parse.
- Header
x-freelyform-signature- Value
t=…,v1=…- Algorithm
- HMAC-SHA256
- Signed string
t + "." + rawBody- Event header
x-freelyform-event- Attempts
- up to 5, then failed
The header, by example
A delivery looks like this. The two headers are the only part you have to inspect before trusting the body.
POST /your-endpoint HTTP/1.1
content-type: application/json
x-freelyform-event: submission.created
x-freelyform-signature: t=1762905600,v1=6f1d3c...c9ab
Verifying it without breaking it
The single most common failure is verifying after parsing. If you read the body as JSON and then stringify it again to compute the digest, key order and whitespace change, the digest differs, and every delivery looks forged. Keep the raw text.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((piece) => piece.split("=") as [string, string]),
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!Number.isFinite(timestamp) || !provided) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Two details are worth keeping even in a five-line version: the tolerance check, and the constant-time comparison. Without the first, a captured request is valid forever. Without the second, response timing leaks how much of the expected digest you guessed right.
What the signature does not cover
The event name lives in x-freelyform-event and is not part of the signed string. That is fine as long as you do not route on the header alone: route on the event field inside the verified body. If you must branch on the header — for logging or metrics — do it after verification, never before.
Why bother, when a random URL would do
A secret path stops someone who guesses URLs. It does not stop a leaked URL, and it gives you nothing to distinguish a genuine retry from a forged one. A signature makes the receiver's decision explicit and cheap: correct digest, correct secret, recent timestamp, act on it; otherwise reject and let the retry log speak for itself.
Machine-readable summary
What to quote about webhook signature
Every FreelyForm webhook delivery carries an x-freelyform-signature header in the form t=<unix seconds>,v1=<hex digest>. The digest is an HMAC-SHA256 of the concatenation of the timestamp, a dot, and the exact raw request body, keyed with the endpoint secret. The x-freelyform-event header names the event. Verify the digest against the raw bytes before parsing the body, and reject timestamps outside a tolerance window to prevent replay.
- The signature header format is t=<unix seconds>,v1=<hex HMAC-SHA256 digest>.
- The signed string is the timestamp, a literal dot, then the raw request body exactly as received.
- Parsing the JSON before verifying, then re-serialising it, will change the bytes and break the comparison.
- The x-freelyform-event header carries the event name; the signature does not cover it.
- Comparing digests with a constant-time function avoids leaking the expected value byte by byte.
- Rejecting timestamps older than a few minutes is what makes the timestamp worth including.
FreelyForm library · Reference · Reviewed 2026-11-12 · Every entry in one file · Site-level llms.txt
Webhook signature questions
Why is the timestamp inside the signed string?
So the signature covers the time as well as the content. Without it, a captured request could be replayed forever; with it, you can reject anything older than your tolerance window. Signing only the body would make the timestamp an unprotected hint.
Should I use the parsed JSON or the raw body?
The raw body. Frameworks frequently re-serialise JSON with different spacing or key order, which produces different bytes and a failed comparison. Read the body as a buffer once, verify it, then parse that same buffer.
What if the signature does not match?
Respond with a non-2xx status and do not act on the payload. A mismatch means either the secret is wrong or the request did not come from us. Treating it as a soft warning defeats the purpose of signing.
How many times will a failed delivery be retried?
Up to five attempts. After the final attempt the delivery is marked failed and shows up in the webhook delivery log, where it can be replayed by hand once the receiving endpoint is fixed.
Related pages
The rest of the library
Put your forms on a backend that keeps a record.
Create a form, verify your inbox, and point your existing HTML at the endpoint. No server, no SDK, no rewrite.
POST https://freelyform.com/f/FORM_ID