Skip to content
Insy
For developers
Esc
navigateopen⌘Jpreview
On this page

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_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 for the payload of each type.

Was this page helpful?