Verifying webhook signatures
Every webhook carries an HMAC-SHA256 signature in the X-Axisel-Signature header. Verify it against the raw request body using your webhook's signing secret. Never trust unsigned payloads.
⚠Use the raw request body for signature computation — not the parsed JSON. Frameworks that re-serialize body before your handler runs will produce a different hash.
Header format
httpX-Axisel-Signature: sha256=a1b2c3d4e5f6...
Node.js (Express)
javascriptimport express from 'express'; import crypto from 'crypto'; const app = express(); const SECRET = process.env.AXISEL_WEBHOOK_SECRET; // Capture raw body for signature verification app.post( '/webhooks/axisel', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-axisel-signature'] || ''; const provided = signature.replace(/^sha256=/, ''); const expected = crypto .createHmac('sha256', SECRET) .update(req.body) .digest('hex'); if ( provided.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex')) ) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(req.body.toString()); // Dedupe on event.id (X-Axisel-Delivery-Id), process asynchronously res.status(200).send('ok'); } );
Python (Flask)
pythonfrom flask import Flask, request, abort import hmac, hashlib, os app = Flask(__name__) SECRET = os.environ['AXISEL_WEBHOOK_SECRET'] @app.post('/webhooks/axisel') def webhook(): signature = request.headers.get('X-Axisel-Signature', '') provided = signature.removeprefix('sha256=') expected = hmac.new( SECRET.encode(), request.get_data(), # raw body hashlib.sha256, ).hexdigest() if not hmac.compare_digest(provided, expected): abort(401) event = request.get_json() # process event... return ('', 200)
PHP
php<?php $secret = getenv('AXISEL_WEBHOOK_SECRET'); $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_AXISEL_SIGNATURE'] ?? ''; $provided = preg_replace('/^sha256=/', '', $signature); $expected = hash_hmac('sha256', $payload, $secret); if (!hash_equals($expected, $provided)) { http_response_code(401); exit('Invalid signature'); } $event = json_decode($payload, true); // process event... http_response_code(200);
Common pitfalls
- Using parsed JSON instead of raw body — different bytes, different hash.
- String equality instead of
hash_equals/timingSafeEqual— exposes you to timing attacks. - Trailing newline or BOM introduced by middleware — capture body exactly as received.
- Missing the
sha256=prefix strip before comparing.