Verify a Webhook Signature

Verify the HMAC-SHA256 signature on incoming CatalystPay webhook requests.

# Not applicable -- verification happens on your server.
# Example: check the signature header on an incoming request.
# Header: X-CatalystPay-Signature: <hex-encoded HMAC-SHA256>
import hmac
import hashlib
import json

def verify_webhook_signature(
    request_body: bytes, signature_header: str, signing_secret: str
) -> bool:
    """Verify HMAC-SHA256 signature of a CatalystPay webhook payload."""
    payload = json.loads(request_body)
    canonical_body = json.dumps(payload, sort_keys=True, separators=(',', ':'))

    expected = hmac.new(
        signing_secret.encode('utf-8'),
        canonical_body.encode('utf-8'),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature_header)


# Django usage:
signature = request.headers.get('X-CatalystPay-Signature', '')
if not verify_webhook_signature(request.body, signature, signing_secret):
    return HttpResponseForbidden('Invalid signature')

# NOTE: The server serializes payloads using Django's DjangoJSONEncoder
# (from django.core.serializers.json), which handles Decimal, UUID,
# datetime, and date values. If you are NOT using Django, you must
# ensure these types serialize identically:
#   - Decimal  -> string (e.g., "29.99")
#   - UUID     -> hyphenated lowercase (e.g., "a1b2c3d4-...")
#   - datetime -> ISO 8601 with "T" separator (e.g., "2025-01-15T10:30:00Z")
#   - date     -> ISO 8601 (e.g., "2025-01-15")
const crypto = require('crypto');

// Recursively sort object keys to match Python's sort_keys=True
function sortKeys(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sortKeys);
  return Object.keys(obj).sort().reduce((acc, key) => {
    acc[key] = sortKeys(obj[key]);
    return acc;
  }, {});
}

function verifyWebhookSignature(rawBody, signatureHeader, signingSecret) {
  const payload = JSON.parse(rawBody);
  // Compact JSON with sorted keys -- matches Python's
  // json.dumps(payload, sort_keys=True, separators=(',', ':'))
  const canonical = JSON.stringify(sortKeys(payload));

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(canonical)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signatureHeader, 'hex')
  );
}

// Express usage:
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-catalystpay-signature'] || '';
  if (!verifyWebhookSignature(req.body.toString(), signature, signingSecret)) {
    return res.status(403).send('Invalid signature');
  }
  // Process webhook...
  res.sendStatus(200);
});

Webhook headers:

Content-Type: application/json
X-CatalystPay-Event: order.created
X-CatalystPay-Signature: 5a3f8e2b1c9d7f6a4e0b...
User-Agent: CatalystPay-Webhook/1.0

CatalystPay signs compact JSON with sorted keys (sort_keys=True, separators=(',', ':')) -- always re-serialize to this canonical form before computing the HMAC. Return a 2xx within 15 seconds; non-2xx triggers exponential retries (up to 5). See Webhooks for the full guide.