Webhooks
Signature Verification
Verify the X-Halfin-Signature header before processing any webhook event.
Every webhook includes an X-Halfin-Signature header. Always verify this signature before processing the event. Reject any request where verification fails.
Signature format
X-Halfin-Signature: t=1735689900,v1=5d41402abc4b2a76b9719d911017c592| Part | Description |
|---|---|
t | Unix timestamp in seconds |
v1 | HMAC-SHA256 of the preimage, hex-encoded |
Algorithm
preimage = "{t}.{raw_body}"
v1 = hex(HMAC-SHA256(webhook_secret, preimage))- Extract
tandv1from the header by splitting on,then=. - Build the preimage: the string
{t}.followed by the raw request body bytes. - Compute
expected = HMAC-SHA256(webhook_secret, preimage)and hex-encode it. - Compare
expectedwithv1using a constant-time comparison function. - Reject if
|now - t| > 300seconds.
Verify on the raw body bytes before any JSON parsing. The webhook secret is the one from Settings → Webhooks — not your API key.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(
signatureHeader: string,
rawBody: Buffer,
secret: string,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const [key, ...value] = part.split('=');
return [key, value.join('=')];
}),
);
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) return false;
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(provided)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'));
}Python
import hashlib
import hmac
import time
def verify_webhook(signature_header: str, raw_body: bytes, secret: str) -> bool:
parts = {}
for part in signature_header.split(','):
key, _, value = part.partition('=')
parts[key] = value
timestamp = parts.get('t')
provided = parts.get('v1')
if not timestamp or not provided:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > 300:
return False
preimage = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), preimage, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided)With the SDK
import { verifySignature } from '@halfin/sdk-merchant';
const isValid = verifySignature({
signature: req.headers['x-halfin-signature'] as string,
body: rawBody,
secret: process.env.HALFIN_WEBHOOK_SECRET!,
toleranceSeconds: 300,
});Common mistakes
| Mistake | Fix |
|---|---|
| Parsing JSON before computing HMAC | Use the raw request body bytes |
Using === to compare signatures | Use constant-time comparison (timingSafeEqual / hmac.compare_digest) |
| Skipping timestamp check | Validate |now - t| ≤ 300 seconds |
| Using the API key as the secret | Use the webhook secret from Settings → Webhooks |
Checking X-Timestamp or X-Signature separately | There is only one header: X-Halfin-Signature |