Why you need to verify webhook signatures
A webhook endpoint is a public URL whether you meant it to be or not — it turns up in browser history, server logs, a support-ticket screenshot, or a config file that leaks into a public repo. Once someone has it, they can POST anything they like: same content type, same JSON shape, any field values they choose. If your handler creates a CRM record, fires a Slack alert, or sends an email for every request that hits that URL with no other check, you've built an open door, not a webhook.
A signature closes it. splitforms signs every webhook (Starter plan and above) with the request body's HMAC-SHA256 hash, so your endpoint can prove two things before it trusts a payload: that whoever sent it knows your webhook's shared secret, and that the body hasn't changed since it left splitforms' servers. HTTPS alone gives you neither — it protects the request in transit, but does nothing to stop someone else from sending a well-formed POST to the same URL. See the webhooks feature page for what ships on the signed-delivery tier, or start with setting up your first webhookif you haven't wired one up yet.
How HMAC signing actually works
HMAC needs one thing both sides already have: a shared secret, generated when you create the webhook and visible only in your dashboard — never in the browser, never in your form's HTML. To sign a request, splitforms runs the exact bytes of the outgoing body through SHA-256, keyed with that secret, producing a fixed-length hex digest that changes completely if a single byte of the body changes. That digest gets a sha256= prefix — the same convention GitHub and Stripe-style webhook senders use — and ships in the X-Splitforms-Signature header alongside the request.
Verification just runs the same recipe in reverse. Your endpoint takes the raw bytes it received, hashes them with SHA-256 using its own copy of the secret, and compares the result to the header. Matching digests prove two things at once: the sender knew the secret, and the body arrived exactly as sent — HMAC's avalanche effect means even a one-character edit produces a completely different hash. A mismatch means one of three things: wrong secret, altered body, or — by far the most common in practice — you hashed the wrong bytes. More on that in the gotchas section below.
The X-Splitforms-Signature header, worked example
Here's what an actual signed delivery looks like on the wire. The body is compact JSON — no pretty-printing, no extra whitespace — because that's the literal string the signature was computed over:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Splitforms-Signature: sha256=6f2c1a9e4b7d3f0a1c8e5b2d9f4a7c3e1b6d8f2a5c9e3b7d1f4a8c2e6b9d3f7a
X-Splitforms-Event: submission.created
User-Agent: splitforms.com-webhook/1.0
{"event":"submission.created","submission":{"id":"9f2b7c3a-4e2d-4c1a-9d0e-1a2b3c4d5e6f","form_name":"Contact form","data":{"name":"Ada Lovelace","email":"ada@example.com","message":"Hello, world."},"ip_address":"203.0.113.42","referer":"https://example.com/contact","created_at":"2026-07-16T10:00:00.000Z"}}Field names and values above are illustrative — your own payload carries whatever fields your form defines, nested under submission.data.
The header is one string: the literal prefix sha256= followed by 64 hex characters — SHA-256's digest, hex-encoded. Nothing else lives in that header: no timestamp, no algorithm name to parse out, no delimiter beyond the =. To see this shape against your own payloads before you write verification code, the webhook tester inspects raw bodies client-side, and the full field-by-field contract — endpoint, headers, timeout, status codes — is in the API reference. Haven't added a destination URL yet? Form to webhook covers the click-by-click dashboard setup.
Verify in Node (Express)
The one rule that matters more than any line of code: hash the raw body, not a parsed-and-re-serialized one. In Express, mount express.raw() on the webhook route specifically, ahead of any express.json() middleware, so req.body stays a Buffer of the exact bytes received:
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.SPLITFORMS_WEBHOOK_SECRET!;
app.post(
"/webhooks/splitforms",
express.raw({ type: "application/json" }), // req.body stays a Buffer
(req, res) => {
const header = req.get("X-Splitforms-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.status(401).send("invalid signature");
const payload = JSON.parse(req.body.toString("utf8"));
res.status(200).send("ok"); // acknowledge, then process payload.submission
},
);timingSafeEqualthrows if the two buffers aren't the same length, so the length check has to run first — that's not a shortcut, it's required. Checking length separately only leaks the length of a hex digest, which is already public (SHA-256 is always 64 hex characters), so it costs you nothing.
Verify in Python (FastAPI)
Same rule, different framework: read the body before anything parses it as JSON. FastAPI's await request.body() returns the raw bytes — call it before request.json() runs anywhere in the handler:
import hashlib
import hmac
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["SPLITFORMS_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/splitforms")
async def splitforms_webhook(request: Request):
raw_body = await request.body() # exact bytes, before json parsing
header = request.headers.get("x-splitforms-signature", "")
digest = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
if not hmac.compare_digest(header, expected):
raise HTTPException(status_code=401, detail="invalid signature")
payload = await request.json()
return {"ok": True} # acknowledge, then process payload["submission"]hmac.compare_digest is Python's constant-time comparator — it exists specifically for this job, so there's no manual length check to write the way Node requires. Flask's equivalent is request.get_data() called before request.get_json(); the hashing and comparison code is identical either way.
Verify in PHP
PHP's raw body lives at php://input, read once as a stream — no framework-level JSON parsing to route around:
<?php
$secret = getenv('SPLITFORMS_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // exact bytes received
$header = $_SERVER['HTTP_X_SPLITFORMS_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($raw, true);
http_response_code(200);
echo 'ok'; // acknowledge, then process $payload['submission']hash_equals() is PHP's constant-time comparator, built in since 5.6 — pass the value you computed first and the user-supplied header second, matching its documented signature. Skip == or === here for the same reason every other language on this page avoids native string comparison: both short-circuit on the first mismatched byte.
Why your signature check keeps failing
Six mistakes account for nearly every "verification always fails" report:
- Hashing the re-serialized body instead of the raw one. The #1 cause, by a wide margin. Any JSON-parsing middleware that runs before your verification code — Express's
express.json(), Flask's automatic parsing, a global body parser — reconstructs the payload into an object. CallJSON.stringify()on that object to hash it and key order or whitespace can differ from what splitforms actually sent, even though every value is identical. Different bytes, different hash. Capture the raw body before anything parses it as JSON. - Charset or encoding mismatches. HMAC operates on bytes, not on a language's native string type. If your framework decodes the body using a different charset than it was sent in, or you re-encode it before hashing, you're no longer hashing the bytes splitforms signed.
- A trailing newline you didn't know was there. Piping a payload through a shell variable, saving it with an editor, or reading it via certain helper methods can silently append a newline the original body never had. It looks identical in a terminal and fails every time.
- Comparing signatures with
===. A plain equality check exits at the first mismatched byte, and that timing difference is measurable over enough attempts — a genuine timing attack, not a theoretical one. Always use your language's constant-time comparator instead. - Comparing a bare hex digest to the prefixed header. The header value is the full string
sha256=<hex>, not just the hex part. Build the prefixed string yourself before comparing, or the check fails even with the right secret and the right bytes. - A secret that doesn't actually match. A trailing newline copy-pasted from a
.envfile, or an old value left behind after rotating the secret in the dashboard, produces a permanently wrong digest that looks exactly like a raw-body bug. Print both digests and diff them before assuming the code is wrong.
Respond fast — there's no second attempt
splitforms delivers each webhook exactly once, with an 8-second timeout, and does not automatically retry a failed delivery — no backoff, no queue, no second POST. That makes your endpoint's own speed and uptime the whole reliability story: verify the signature, return a 2xx immediately, and push anything slower — a CRM write, an email send, a database query — onto a background task instead of doing it inline. A handler that blocks on a slow downstream call risks missing the 8-second window, and nothing is coming after it to catch that miss.
None of this puts the underlying submission at risk — a failed webhook delivery doesn't affect the copy stored in your dashboard or the notification email, which both go out regardless of what your endpoint does with the webhook. But if you want durable delivery on your side — replaying a delivery your endpoint missed, backoff instead of a single 8-second shot, a dead-letter view for repeat failures — that's a layer you build on top of the single POST. Webhook retry strategy covers the idempotency keys, backoff schedule, and dead-letter pattern for exactly that.
FAQ
Can I skip signature verification if my webhook URL is hard to guess?
No. An obscure URL isn't a secret — it can leak through browser history, server logs, a shared screenshot, or a public repo, and once it's known anyone can POST a fake submission to it. Verification is the only check standing between your endpoint and a spoofed request, obscure URL or not.
Is HTTPS enough on its own, without a signature?
No, they solve different problems. HTTPS encrypts the request in transit so nobody can read or tamper with it on the wire. It does nothing to stop a different sender from POSTing a well-formed request to the same URL. The signature is what proves the request came from splitforms specifically.
Why does my signature never match, even when I'm testing locally?
The overwhelming majority of the time, you're hashing the wrong bytes — a JSON-parsing middleware ran before your verification code, and you're hashing its re-serialized output instead of the raw body splitforms actually sent. Capture the body before anything parses it as JSON, in every language covered above.
What's the practical difference between hashing the raw body and the parsed JSON?
The data can be identical and the hash still won't match, because HMAC operates on exact bytes, not on meaning. Re-serializing a parsed object can reorder keys or change whitespace, producing a different byte string — and a different string produces a completely different hash, even though every field value is unchanged.
Which algorithm and header does splitforms use for webhook signatures?
HMAC-SHA256 over the raw request body, sent as the X-Splitforms-Signature header in the form sha256=<hex digest>. Nothing else needs decoding — no timestamp field to parse out, no separate algorithm identifier, just the one prefixed hex string.
Is a timing-safe comparison actually necessary?
Yes, if you're writing the comparison yourself. A plain equality check exits as soon as it hits a mismatched byte, and that timing difference is measurable over repeated attempts against a live endpoint. crypto.timingSafeEqual, hmac.compare_digest, and hash_equals all run in constant time regardless of where the mismatch is.
What happens if I rotate my webhook secret?
Every signature computed after the rotation uses the new secret immediately — there's no overlap window where both the old and new secret verify. Update the secret in your endpoint's environment at the same moment you rotate it in the dashboard, or valid deliveries will fail verification until the two are back in sync.
Want signed webhook delivery without maintaining your own retry pipeline? Get a free splitforms access key — every plan stores and emails submissions, and Pro ($5/mo) adds HMAC-signed webhook delivery to Slack, Discord, or an endpoint you write yourself.