splitforms.com
← Back to the journal

How to Verify Webhook Signatures (HMAC SHA-256)

To verify a webhook signature, read the raw body, compute HMAC-SHA256 with your secret and compare it to the signature header in constant time.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
To verify a webhook signature, read the raw request body, compute an HMAC-SHA256 of it with your webhook secret, and compare the result with the signature header using a constant-time function. splitforms sends that signature as X-Splitforms-Signature: sha256=<hex>, the same format as GitHub's X-Hub-Signature-256. Compare with crypto.timingSafeEqual in Node, hmac.compare_digest in Python or hash_equals in PHP, never ===. Most failures come from hashing a parsed-and-re-serialized body instead of the exact bytes that arrived.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

Last reviewed . Provider headers checked against each provider's docs on the same day; splitforms facts read from its webhook code.

How do I verify a webhook signature?

Verify a webhook signature in three steps: read the raw request body, compute HMAC-SHA256 over it with your secret, and compare the result with the signature header in constant time. If they differ, return 401 and stop.

  1. Read the raw body as bytes, before any JSON middleware parses it.
  2. Compute HMAC-SHA256 over those bytes, keyed with the webhook's secret, and format it the way the provider does. For splitforms that's sha256= plus the hex digest.
  3. Compare in constant time with the signature header: crypto.timingSafeEqual, hmac.compare_digest or hash_equals.
Key facts: splitforms webhook signatures
AlgorithmHMAC-SHA256
HeaderX-Splitforms-Signature
Valuesha256= plus 64 lowercase hex characters
Signed bytesThe raw request body (UTF-8 JSON), nothing else
Timestamp in signatureNone; dedupe on submission.id
SecretOne per webhook, 32 hex characters, shown in your dashboard
Other headersX-Splitforms-Event: submission.created, User-Agent: splitforms.com-webhook/1.0
Timeout and retry8 s per attempt; one retry about 2 s later on a network error, timeout or 5xx; 4xx is never retried
PlansWebhooks come with Pro ($5 per month) and Annual ($49 per year)

Which signature header does each provider use?

GitHub, Stripe, Shopify, Slack and splitforms all sign with HMAC-SHA256, but each signs a different string and formats the header differently. Match the row for the sender you're verifying:

Webhook signature headers by provider
ProviderHeaderValueWhat's signedTimestamp
GitHubValidating webhook deliveriesX-Hub-Signature-256sha256=<hex> (hex)raw bodyNone in the signature
StripeVerify webhook signatures manuallyStripe-Signaturet=<unix time>,v1=<hex> (hex)<t>.<raw body>t= is signed; Stripe's libraries allow 5 minutes by default
ShopifyHTTPS webhook HMAC verificationX-Shopify-Hmac-SHA256<base64> (base64)raw bodyNone in the signature
SlackVerifying requests from SlackX-Slack-Signaturev0=<hex> (hex)v0:<X-Slack-Request-Timestamp>:<raw body>Signed; Slack says reject requests more than 5 minutes old
splitformsAPI referenceX-Splitforms-Signaturesha256=<hex> (hex)raw bodyNone; dedupe on submission.id

Here is one body, {"hello":"world"}, signed by each scheme with the same demo secret 8c0f4b2e9d7a41e6b3f5c1a2d4e6f809 and timestamp 1790899200. GitHub and splitforms produce the same value because they use the same recipe; Shopify base64-encodes the digest; Stripe and Slack fold the timestamp into the signed string, so a replayed request with a new timestamp fails.

GitHub      X-Hub-Signature-256: sha256=d087dc5551436c7e061af11ff0ac606144cd6b62c537d83110d5f12d4cdcb686
Stripe      Stripe-Signature: t=1790899200,v1=85e71496b50a2de56b2a3c21aa9f76a677e64fd330cb004b3bce8e7091c74a9d
Shopify     X-Shopify-Hmac-SHA256: 0IfcVVFDbH4GGvEf8KxgYUTNa2LFN9gxENXxLUzctoY=
Slack       X-Slack-Signature: v0=e05fa918804ec1c899b26895ba8cb98904837bf2579ec6c0c73b1ea2cbfc9494
splitforms  X-Splitforms-Signature: sha256=d087dc5551436c7e061af11ff0ac606144cd6b62c537d83110d5f12d4cdcb686

Generate or verify a webhook signature in your browser.

Computed with Node's crypto.createHmac. The GitHub recipe reproduces the test vector in GitHub's docs, checked in this site's test suite.

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 (on Pro and Annual) 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 webhook if 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 uses for X-Hub-Signature-256 — 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 a signed delivery exactly as splitforms sends it. The body is compact JSON — no pretty-printing, no extra whitespace — because that's the literal string the signature was computed over. The signature below is real: it's the HMAC-SHA256 of this body under the demo secret 8c0f4b2e9d7a41e6b3f5c1a2d4e6f809.

POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Splitforms-Signature: sha256=c847d024ef57d380ac0dfec482b8c21440b6ed97d6b893be6d2a78ef5e06d2bc
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 demo secret is not a real webhook secret.

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 check a captured request by hand, save its exact body to a file and run:

openssl dgst -sha256 -hmac "$SPLITFORMS_WEBHOOK_SECRET" body.json
# the hex digest it prints must equal the header value after "sha256="

The webhook tester inspects captured bodies client-side, so you can confirm what actually arrived before you write verification code. 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
  },
);

timingSafeEqual throws 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.

How do I compare signatures in constant time?

Use your language's built-in constant-time comparator, which takes the same time wherever the first mismatched byte is. GitHub's validation guide gives the same advice: never a plain ==.

// Node: lengths must match first, or timingSafeEqual throws
const a = Buffer.from(received), b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);

# Python
valid = hmac.compare_digest(received, expected)

// PHP: known value first, user-supplied value second
$valid = hash_equals($expected, $received);

Why your signature check keeps failing

These six mistakes explain most "verification always fails" reports:

  • Hashing the re-serialized body instead of the raw one. The most common cause. 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. Call JSON.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 .env file, 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 — and be idempotent

splitforms delivers each webhook with an 8-second timeout and retries once, about 2 seconds later, when the delivery failed with a network error, timeout, or 5xx (a 4xx is never retried — your endpoint understood the request and refused it). Two consequences for your handler: first, speed still matters — verify the signature, return a 2xx immediately, and push anything slower — a CRM write, an email send, a database query — onto a background task. Second, the retry means the same submission can reach you twice (say, you processed it but timed out before responding), so key your side effects on the payload's submission.id and skip duplicates. The signature carries no timestamp, so that dedupe is also your replay protection.

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, a longer backoff schedule than splitforms' one built-in retry, a dead-letter view for repeat failures — that's a layer you build on top of splitforms' delivery attempt. Webhook retry strategy covers the idempotency keys, backoff schedule, and dead-letter pattern for exactly that.

FAQ

How do I verify a webhook signature?

Read the raw request body before any JSON parsing, compute HMAC-SHA256 over those bytes with your webhook secret, format it the way the provider does (sha256=<hex> for GitHub and splitforms), and compare it with the signature header using a constant-time function such as crypto.timingSafeEqual, hmac.compare_digest or hash_equals. Reject the request with 401 if they differ.

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?

Usually 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.

What is X-Hub-Signature-256?

It's GitHub's webhook signature header: sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook secret. splitforms uses the same value format in X-Splitforms-Signature, so the same verification code works for both once you change the header name and secret.

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.

splitforms signs every webhook delivery with X-Splitforms-Signature; each attempt has an 8 s timeout, with one retry after about 2 s on network errors and 5xx. Webhooks to Slack, Discord or an endpoint you write come with Pro ($5 per month) or Annual ($49 per year), and every plan, including Free (500 submissions total), stores and emails submissions. Get a free splitforms access key, or read how to send form data to an API for the browser side of the same pipeline.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Remove the Arrows From a Number Input (CSS, Tailwind, React)

Hide the number input spin buttons with ::-webkit-inner-spin-button and appearance: textfiel…

7 min readRead →
Tutorials

How to Change the Autofill Background Color of an Input (CSS, Tailwind)

Remove Chrome's yellow autofill background with input:-webkit-autofill, inset box-shadow, -w…

7 min readRead →
Tutorials

input type=number maxlength Not Working? 4 Fixes That Work

Why maxlength is ignored on input type=number, plus four fixes: min/max, oninput slice, text…

8 min readRead →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Next steps

Put this article into practice with the matching tool or guide.

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe