---
title: Verifying signatures
description: How to validate the X-Insy-Signature header — HMAC-SHA256 over the raw body, keyed with the secret as a literal string, compared in constant time.
---

Your webhook endpoint is a public URL — anyone can post JSON to it, and the only thing that separates a real Insy delivery from a forgery is the `X-Insy-Signature` header. Verify it before you parse the body or act on anything inside it.

## The scheme

Insy computes:

```text title="Signature"
X-Insy-Signature = hex_lowercase( HMAC_SHA256( key = signing_secret, message = raw_request_body ) )
```

Four properties, all of which matter:

| Property | Value |
| --- | --- |
| Algorithm | HMAC-SHA256 |
| Key | Your signing secret — 64 hexadecimal characters — used as a **literal UTF-8 string** |
| Message | The **raw request body bytes**, exactly as received |
| Encoding | Lowercase hexadecimal |

You get the signing secret when the webhook is created; see [Registering an endpoint](/developers/webhooks) for recovering it.

> **Do not hex-decode the secret**
>
> The secret looks like hex because it is a hex rendering of 32 random bytes — but the HMAC key is the **64-character ASCII string**, not those 32 bytes. Decoding it first produces a completely different digest and every verification will fail.
>
> - Node.js: `crypto.createHmac("sha256", secret)` where `secret` is the string. Not `Buffer.from(secret, "hex")`.
> - Python: `secret.encode("utf-8")`, which gives 64 bytes. Not `bytes.fromhex(secret)`.
> - PHP: pass the string straight to `hash_hmac`. Not `hex2bin($secret)`.
>
> This is the single most common cause of a failing check, and it fails silently — the digest is well-formed, just wrong.

> **Sign the raw body, not a re-serialised one**
>
> The HMAC covers the exact bytes Insy sent. If a JSON body parser has already turned the request into an object, `JSON.stringify()` of that object is a *different* byte sequence — key order, whitespace, number formatting and Unicode escaping all drift — and the signature will not match.
>
> Capture the raw body first: `express.raw({ type: "application/json" })` in Express, `request.get_data()` in Flask, `file_get_contents('php://input')` in PHP. In frameworks that parse globally, exempt the webhook route or stash the raw buffer during parsing.

## Verification steps

1. **Read the raw body**

    As bytes, before any JSON middleware touches it.

2. **Compute the HMAC**

    HMAC-SHA256 over those bytes, keyed with the secret as a string, hex-encoded lowercase.

3. **Compare in constant time**

    Use `crypto.timingSafeEqual`, `hmac.compare_digest` or `hash_equals`. A plain `===` comparison leaks timing information about how many leading characters matched.

4. **Reject or acknowledge**

    On mismatch, return `401` and do nothing else. On success, parse the body, respond `2xx` immediately and queue the work.

## Examples

Each example verifies, then acknowledges before doing any real work, because the delivery times out after 10 seconds.

<CodeGroup>
```js title="Node.js — Express"
import crypto from "node:crypto";
import express from "express";

const app = express();

// 64 hex characters, used as a string. Never Buffer.from(secret, "hex").
const SECRET = process.env.INSY_WEBHOOK_SECRET;

// express.raw gives req.body as a Buffer of the untouched bytes. Mount it on
// this route; if the app also uses express.json(), keep that off this path.
app.post(
  "/webhooks/insy",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const received = req.get("x-insy-signature") ?? "";
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(req.body)
      .digest("hex");

    const a = Buffer.from(received, "utf8");
    const b = Buffer.from(expected, "utf8");
    // timingSafeEqual throws on a length mismatch, so check length first.
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      res.status(401).send("invalid signature");
      return;
    }

    const eventId = req.get("x-insy-event-id");
    const event = JSON.parse(req.body.toString("utf8"));

    // Acknowledge first, process out of band.
    res.status(200).send("ok");
    void enqueue(eventId, event);
  },
);

app.listen(3000);
```

```python title="Python — Flask"
import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)

# .encode() gives the 64 ASCII bytes of the secret. Never bytes.fromhex().
SECRET = os.environ["INSY_WEBHOOK_SECRET"].encode("utf-8")


@app.post("/webhooks/insy")
def insy_webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    received = request.headers.get("X-Insy-Signature", "")
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, received):
        return "invalid signature", 401

    event_id = request.headers.get("X-Insy-Event-Id")
    event = request.get_json()

    # Hand off to a queue; do not do slow work in the request.
    enqueue(event_id, event)
    return "", 200
```

```php title="PHP"
<?php

// 64 hex characters, used as a string. Never hex2bin($secret).
$secret = getenv('INSY_WEBHOOK_SECRET');

// $_POST is empty for JSON bodies — php://input is the raw request.
$raw = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_INSY_SIGNATURE'] ?? '';

// hash_hmac returns lowercase hex.
$expected = hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'invalid signature';
    exit;
}

$eventId = $_SERVER['HTTP_X_INSY_EVENT_ID'] ?? null;
$event = json_decode($raw, true);

http_response_code(200);
echo 'ok';

// Flush the response, then process.
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

enqueue($eventId, $event);
```
</CodeGroup>

## Rejecting a delivery

Returning `401` fails the delivery, so it retries on a later dispatcher run, up to 3 attempts. That is what you want: a forged request keeps failing and is discarded, while a transient misconfiguration — a secret not yet deployed, say — gets a couple more chances. Do not answer `2xx` to unverified requests to dodge retries; a `2xx` tells Insy the event was accepted.

## When the check fails

- **The secret was hex-decoded.** The most common cause. Use the 64 characters as a string.
- **The body was re-serialised.** A global JSON parser ran first and you hashed `JSON.stringify(req.body)`. Hash the raw bytes.
- **The body was mutated in transit.** A proxy, a WAF or a framework that rewrites, pretty-prints or re-encodes the payload changes the bytes and therefore the digest. Verify as early in the stack as you can.
- **The wrong secret.** Each webhook has its own secret. If the account has several endpoints, or the endpoint was recreated, you may be holding a secret for a different one.
- **The header never arrived.** Header names are case-insensitive, but some proxies strip unknown `X-` headers. Log the received header set when the value is empty.
- **The comparison normalised the value.** The digest is lowercase hex; trimming, upper-casing or comparing after decoding will not match.
- **The wrong string was compared.** `hash_equals`, `compare_digest` and `timingSafeEqual` take the expected and received signatures, both as strings — not the raw body, not a parsed object.

Once the signature verifies, continue with [Events](/developers/webhooks/events) for the payload of each type.
