Signature verification

Every webhook delivery is signed with HMAC-SHA256 using your endpoint secret. Verifying the signature before processing events is essential — it prevents replay attacks and spoofed payloads.

The signature header

The signature is delivered in the X-HLD-Signature-256 header in the format sha256=<hex_digest>. The digest is computed over the raw, unparsed request body.

Warning:Always verify against the raw bytes of the request body — not a parsed JSON object. Serialisation differences will cause valid signatures to fail.

Node.js

typescript
import crypto from 'crypto'
import type { Request, Response } from 'express'

export function webhookHandler(req: Request, res: Response) {
  const signature = req.headers['x-hld-signature-256'] as string
  const secret = process.env.HLD_WEBHOOK_SECRET!

  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(req.rawBody) // express: use bodyParser with { verify: ... }
    .digest('hex')

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).send('Signature mismatch')
  }

  const event = JSON.parse(req.rawBody)
  // handle event...
  res.status(200).send('ok')
}

Python

python
import hmac
import hashlib
from flask import request, abort

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(), payload, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.route('/webhook', methods=['POST'])
def webhook():
    sig = request.headers.get('X-HLD-Signature-256', '')
    if not verify_signature(request.data, sig, HLD_WEBHOOK_SECRET):
        abort(401)
    event = request.get_json()
    # handle event...
    return 'ok', 200

Replay protection

Every webhook payload includes a created_at timestamp. Reject events where created_at is more than 5 minutes in the past to protect against replay attacks.

typescript
const event = JSON.parse(req.rawBody)
const ageMs = Date.now() - new Date(event.created_at).getTime()
if (ageMs > 5 * 60 * 1000) {
  return res.status(400).send('Stale event')
}