Every webhook request from Open includes an X-Webhook-Signature HTTP header. This header contains an HMAC-SHA-256 hex digest of the raw request body, computed using your Webhook Signing Secret.
Where to get the Webhook Signing Secret
The Webhook Signing Secret is generated by OPEN when a webhook URL is configured. You can obtain it in two ways:
- From OPEN -- shared during onboarding or upon request.
- From the Dashboard -- navigate to
Connected Banking Platform Dashboard -> Webhook Configurationand copy the signing secret displayed alongside your webhook URL.
The Webhook Signing Secret is not the same as your API
SECRET_KEY. Each webhook URL has its own unique signing secret. Store it securely and never expose it in client-side code.
Verification steps
Step 1 -- Read the X-Webhook-Signature header from the incoming request.
Step 2 -- Read the raw request body as bytes / string. Do not parse or re-serialize it.
Step 3 -- Compute an HMAC-SHA-256 hex digest over the raw body using your Webhook Signing Secret.
expected = hmac_sha256( webhook_signing_secret, raw_request_body ) -> hex digest
Step 4 -- Compare the computed digest with the value from X-Webhook-Signature:
- Match -- payload is authentic, process it.
- Mismatch -- discard as invalid / untrusted.
Reference implementations
Node.js
const crypto = require('crypto');
function isValidWebhook(rawBody, signatureHeader, webhookSigningSecret) {
const expected = crypto
.createHmac('sha256', webhookSigningSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signatureHeader, 'hex')
);
}
// Express.js usage:
// app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// const signature = req.headers['x-webhook-signature'];
// if (!isValidWebhook(req.body, signature, WEBHOOK_SIGNING_SECRET)) {
// return res.status(401).send('Invalid signature');
// }
// const payload = JSON.parse(req.body);
// // process payload...
// });PHP
function isValidWebhook(string $rawBody, string $signatureHeader, string $webhookSigningSecret): bool {
$expected = hash_hmac('sha256', $rawBody, $webhookSigningSecret);
return hash_equals($expected, $signatureHeader);
}
// Usage:
// $rawBody = file_get_contents('php://input');
// $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
// if (!isValidWebhook($rawBody, $signature, $webhookSigningSecret)) {
// http_response_code(401);
// exit('Invalid signature');
// }
// $payload = json_decode($rawBody, true);Python
import hmac, hashlib
def is_valid_webhook(raw_body: bytes, signature_header: str, webhook_signing_secret: str) -> bool:
expected = hmac.new(
webhook_signing_secret.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# Flask usage:
# @app.route('/webhook', methods=['POST'])
# def handle_webhook():
# signature = request.headers.get('X-Webhook-Signature', '')
# if not is_valid_webhook(request.data, signature, WEBHOOK_SIGNING_SECRET):
# abort(401)
# payload = request.get_json()
# # process payload...Always use a constant-time comparison (
crypto.timingSafeEqual/hash_equals/hmac.compare_digest) to prevent timing attacks. Always read the raw body before JSON-parsing it -- re-serializing a parsed object may alter key order or whitespace, producing a different hash.