Verifying the checksum
Validate the x-fsk-wh-chksm checksum on every legacy delivery before trusting it, with worked example and code in Node.js, Python, and C#.
Every legacy delivery is signed, and your handler must verify the checksum before doing anything else.
A webhook endpoint is a public URL that accepts POSTs from the internet. Without verification, anyone who learns the URL can post a forged sale.completed and have your system ship goods for a payment that never happened. Never skip this.
If your deliveries carry svix-id, svix-timestamp, and svix-signature instead of x-fsk-wh-chksm, you are on Svix webhooks - see Verifying signatures.
The header
Every legacy delivery includes one header:
| Header | Meaning |
|---|---|
x-fsk-wh-chksm |
A lowercase hex-encoded HMAC-SHA256 of the raw request body, keyed with your shared secret. |
How the checksum is computed
- Input - the raw request body, exactly as received.
- Key - the shared secret support provided for this endpoint, as UTF-8.
- Output - HMAC-SHA256, encoded as lowercase hex.
Use the raw JSON exactly as received in the HTTP request body - no reformatting, indentation changes, or whitespace modifications. Any framework that parses and re-serializes the body before you compute the HMAC will produce a checksum that never matches.
Worked example
This minimal JSON body demonstrates the checksum calculation. It is not a complete webhook event; see Event types for the event payload reference. Hash exactly these bytes, without a trailing newline:
{"event":{"id":"evt_01JS21X856RR8R69GV5F17XK9C","type":"sale.completed","timestamp":"2025-04-16T14:30:00Z"}}
and the shared secret shared-secret-from-support, the HMAC-SHA256 encoded as lowercase hex is:
862c6f2473c97472be4262b04670e777b2ed145d12d8a1c79ffc52ea9cfe7f0f
The x-fsk-wh-chksm header must match that value exactly.
Implementation
There is no library for this check; it is small enough to write directly. Compare with a constant-time function, not ==.
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.WEBHOOK_SHARED_SECRET;
function verifyLegacy(rawBody, receivedChecksum) {
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(receivedChecksum ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw is important: the check needs the unparsed body.
app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyLegacy(req.body, req.header("x-fsk-wh-chksm"))) {
return res.sendStatus(400);
}
try {
await enqueue(JSON.parse(req.body)); // persist before acknowledging
} catch {
return res.sendStatus(503);
}
return res.sendStatus(202);
});
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SHARED_SECRET"].encode("utf-8")
def verify_legacy(raw_body: bytes, received: str | None) -> bool:
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received or "")
@app.post("/webhooks")
def webhooks():
# request.data is the raw body, not the parsed JSON.
if not verify_legacy(request.data, request.headers.get("x-fsk-wh-chksm")):
abort(400)
enqueue(request.get_json())
return "", 202
using System.Security.Cryptography;
using System.Text;
app.MapPost("/webhooks", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync();
var secret = Environment.GetEnvironmentVariable("WEBHOOK_SHARED_SECRET")!;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexStringLower(
hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
var received = request.Headers["x-fsk-wh-chksm"].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(received)))
{
return Results.BadRequest();
}
Enqueue(payload);
return Results.Accepted();
});
No replay protection
The checksum covers only the body and carries no timestamp, so a captured delivery stays valid indefinitely and can be replayed. Deduplicating on event.id - which you need anyway - is what closes this gap: a replayed event carries an event.id you have already processed. See Deduplicate on event.id.
The examples use an application-provided enqueue / Enqueue function. It must save the event durably before returning, and fail the request if the save fails. Keep JSON-parsing middleware after the raw-body webhook route.
Order of operations
Read the raw body
Before any JSON parsing or middleware transforms it.
Verify the checksum
Reject with 400 if verification fails. Do not log the payload contents of an unverified request as though it were genuine.
Deduplicate on event ID
Retries and replays mean the same event can arrive more than once.
Save, acknowledge, then process
Save the verified event to a durable queue, then return 2xx promptly. Process the queued event asynchronously. Return a non-2xx response if saving fails.
This is not the same as redirect verification
The Hosted Payment Form redirect hash is a separate mechanism with a different input, secret, and encoding. Verifying one does not verify the other. See the comparison table.