> For the complete documentation index, see [llms.txt](https://docs.omni.integratedcommerce.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.omni.integratedcommerce.io/webhooks/legacy/verifying-the-checksum.md).

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

{% hint style="warning" %}
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.
{% endhint %}

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](/webhooks/svix/verifying-signatures.md).

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

```mermaid
flowchart LR
    B["Raw request body<br/>byte for byte, as received"] --> H["HMAC-SHA256"]
    K["Key: the shared secret from support, UTF-8"] --> H
    H --> O["x-fsk-wh-chksm header<br/>lowercase hex"]
```

{% hint style="danger" %}
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.
{% endhint %}

## Worked example

This minimal JSON body demonstrates the checksum calculation. It is not a complete webhook event; see [Event types](/webhooks/events.md) for the event payload reference. Hash exactly these bytes, without a trailing newline:

```json
{"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 `==`.

{% tabs %}
{% tab title="Node.js" %}

```javascript
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);
});
```

{% endtab %}

{% tab title="Python" %}

```python
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
```

{% endtab %}

{% tab title="C#" %}

```csharp
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();
});
```

{% endtab %}
{% endtabs %}

## No replay protection

{% hint style="warning" %}
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](/webhooks/webhooks.md#deduplicate-on-event-id).
{% endhint %}

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

{% stepper %}
{% step %}

## Read the raw body

Before any JSON parsing or middleware transforms it.
{% endstep %}

{% step %}

## Verify the checksum

Reject with `400` if verification fails. Do not log the payload contents of an unverified request as though it were genuine.
{% endstep %}

{% step %}

## Deduplicate on event ID

Retries and replays mean the same event can arrive more than once.
{% endstep %}

{% step %}

## 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.
{% endstep %}
{% endstepper %}

## This is not the same as redirect verification

The [Hosted Payment Form redirect hash](/hosted-payment-form/redirect-verification.md) is a separate mechanism with a different input, secret, and encoding. Verifying one does not verify the other. See the [comparison table](/hosted-payment-form/redirect-verification.md#this-is-not-the-same-as-webhook-verification).
