Webhooks

Signature Verification

Verify webhook signatures before processing events.

Every webhook includes an X-Halfin-Signature header. Always verify this signature before processing the event.

Signature format

X-Halfin-Signature: t=1735689900,v1=5d41402abc4b2a76b9719d911017c592
PartDescription
tUnix timestamp in seconds
v1HMAC-SHA256 hex digest

Algorithm

HMAC-SHA256(webhook_secret, "{timestamp}.{raw_body}")
  1. Extract t and v1 from the header.
  2. Compute expected = HMAC-SHA256(secret, "{t}.{raw_body}").
  3. Compare expected with v1 using constant-time comparison.
  4. Reject if |now - t| > 300 seconds.

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,
});

Raw Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(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'));
}

Common mistakes

MistakeFix
Parsing JSON before computing HMACUse the raw request body bytes
Using === to compare signaturesUse constant-time comparison
Skipping timestamp checksValidate t is within 300 seconds
Using the API key as the secretUse the webhook secret from Settings -> Webhooks