Webhook Signature Validation

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 Configuration and 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.