Verify signatures
Anyone can send a fake POST to your webhook URL claiming “order DH-1024 is paid”. The signature is the only way to know a request really comes from Hensa and hasn’t been altered.
Format
Section titled “Format”Hensa-Signature: t=1762156992,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt– signing time (Unix seconds).v1–HMAC-SHA256(secret, "<t>.<raw body>"), hex. For 24 hours after a secret rotation there are twov1values.
How to verify
Section titled “How to verify”- Read the request’s raw body (bytes) – before parsing JSON.
- Extract
tand everyv1value from theHensa-Signatureheader. - Reject if
tis more than 5 minutes away from your server clock (replay protection). - Compute
HMAC-SHA256(secret, t + "." + body)and compare it in constant time with eachv1. One match means valid.
import crypto from 'node:crypto'
/** Returns true when the request really comes from Hensa and is fresh (≤ 300 s). */export function verifyHensaSignature(rawBody, header, secret, toleranceSec = 300) { const parts = (header ?? '').split(',').map((p) => p.trim().split('=')) const t = parts.find(([k]) => k === 't')?.[1] const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v) // 2 signatures while a secret rotates if (!t || signatures.length === 0) return false if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false // replay protection
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex')) )}<?php// webhooks/hensa.php – plain PHP (works in Laravel/WordPress too: read the raw body, not the parsed array)$payload = file_get_contents('php://input');$header = $_SERVER['HTTP_HENSA_SIGNATURE'] ?? '';$secret = getenv('HENSA_WEBHOOK_SECRET');
$t = null; $signatures = [];foreach (explode(',', $header) as $part) { [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null); if ($k === 't') { $t = $v; } if ($k === 'v1') { $signatures[] = $v; }}if (!$t || abs(time() - (int) $t) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);$valid = false;foreach ($signatures as $s) { if (hash_equals($expected, $s)) { $valid = true; break; } }if (!$valid) { http_response_code(400); exit; }
$event = json_decode($payload, true);// Idempotent: skip if $event['id'] was already processed.if ($event['type'] === 'payment_request.paid') { mark_order_paid($event['data']['externalOrderRef'], $event['data']['id']);}http_response_code(200);# Flaskimport hashlib, hmac, os, timefrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ["HENSA_WEBHOOK_SECRET"].encode()
def verify(raw: bytes, header: str, tolerance: int = 300) -> bool: items = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p] t = next((v for k, v in items if k == "t"), None) signatures = [v for k, v in items if k == "v1"] if not t or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in signatures)
@app.post("/webhooks/hensa")def hensa_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify(raw, request.headers.get("Hensa-Signature", "")): abort(400) event = request.get_json() # Idempotent: skip if event["id"] was already processed. if event["type"] == "payment_request.paid": mark_order_paid(event["data"]["externalOrderRef"], event["data"]["id"]) return "", 200Common pitfalls
Section titled “Common pitfalls”| Symptom | Cause | Fix |
|---|---|---|
| Signature always wrong | The framework parsed and re-serialised the JSON – the bytes changed | Use the raw body: Express express.raw(), Laravel $request->getContent(), Next.js await req.text() |
| Wrong after rotating the secret | Still using the old secret | Deploy the new secret; both signatures are valid for 24 hours |
| Sometimes right, sometimes wrong | Server clock drift | Enable time sync (NTP) |
Comparing with == |
Vulnerable to timing attacks | Use timingSafeEqual / hash_equals / hmac.compare_digest |
Rotating the secret
Section titled “Rotating the secret”Dashboard › Developer › Webhooks › Rotate secret (or POST /webhook_endpoints/{id}/rotate_secret). The old secret stays valid for 24 hours; meanwhile every event carries two v1 signatures – deploy the new secret whenever you like without missing an event.