Verifying signatures
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:
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 for recovering it.
Verification steps
Read the raw body
As bytes, before any JSON middleware touches it.
Compute the HMAC
HMAC-SHA256 over those bytes, keyed with the secret as a string, hex-encoded lowercase.
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.
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.
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);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
// 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);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_digestandtimingSafeEqualtake the expected and received signatures, both as strings — not the raw body, not a parsed object.
Once the signature verifies, continue with Events for the payload of each type.