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.