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
PartDescription
tUnix timestamp in seconds
v1HMAC-SHA256 of the preimage, hex-encoded

Algorithm

preimage = "{t}.{raw_body}"
v1 = hex(HMAC-SHA256(webhook_secret, preimage))
  1. Extract t and v1 from the header by splitting on , then =.
  2. Build the preimage: the string {t}. followed by the raw request body bytes.
  3. Compute expected = HMAC-SHA256(webhook_secret, preimage) and hex-encode it.
  4. Compare expected with v1 using a constant-time comparison function.
  5. Reject if |now - t| > 300 seconds.

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

MistakeFix
Parsing JSON before computing HMACUse the raw request body bytes
Using === to compare signaturesUse constant-time comparison (timingSafeEqual / hmac.compare_digest)
Skipping timestamp checkValidate |now - t| ≤ 300 seconds
Using the API key as the secretUse the webhook secret from Settings → Webhooks
Checking X-Timestamp or X-Signature separatelyThere is only one header: X-Halfin-Signature