- BD-API signs every webhook with HMAC-SHA256 over
timestamp.raw_body(yes, joined by a literal dot).- Three things will silently break your verification: re-stringifying the parsed body, comparing signatures with
===, and skipping the timestamp check.- Copy-paste examples below for Node (Express + Fastify), Python and PHP.
1. Why webhook signature verification matters
Your webhook endpoint is public. The whole internet can POST to it. Without verification, you have no way to tell a legitimate BD-API event from a forged request someone fired at your URL.
The standard answer is HMAC with a shared secret. BD-API and your server share a secret string. BD-API signs each webhook with that secret, your server recomputes the signature the same way, and if they match — the request is legitimate. If they don't — drop it on the floor.
The crypto is the easy part. The traps live in the details.
2. How BD-API signs
When BD-API delivers a webhook, three headers ride along:
X-BDAPI-Event: ec.publication.detected
X-BDAPI-Timestamp: 1716624000
X-BDAPI-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
The signature is computed like this:
message = `${X-BDAPI-Timestamp}.${raw_body}`
signature = HMAC-SHA256(shared_secret, message)
header = `sha256=${hex(signature)}`
Two non-negotiables:
raw_bodyis the exact bytes your server received — not the parsed JSON, and notJSON.stringify()of the parsed object. Bytes off the wire.- The timestamp is inside the signed message — so an attacker cannot forge a fresh timestamp on a captured payload without breaking the signature. You still have to reject stale timestamps yourself (see Mistake 3): BD-API does not enforce the age window for you.
3. Verification in four environments
Node.js with Express
The trick: grab the raw body before express.json() parses it. The verify callback gives you exactly that hook.
const express = require('express')
const crypto = require('crypto')
const app = express()
// Stash the raw body on req.rawBody before JSON parsing.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf }
}))
function verifyBDAPISignature(req, secret) {
const ts = req.headers['x-bdapi-timestamp']
const sig = req.headers['x-bdapi-signature']
if (!ts || !sig || !req.rawBody) return false
// Replay window: 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// Strip the "sha256=" prefix
const received = sig.startsWith('sha256=') ? sig.slice(7) : sig
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody.toString('utf8')}`)
.digest('hex')
// Constant-time comparison — never use ===
const a = Buffer.from(expected)
const b = Buffer.from(received)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
app.post('/webhooks/bdapi', (req, res) => {
if (!verifyBDAPISignature(req, process.env.BDAPI_WEBHOOK_SECRET)) {
return res.status(401).send({ error: 'invalid_signature' })
}
console.log('Legit event:', req.body.event)
res.status(200).send({ ok: true })
})
Node.js with Fastify
Fastify parses JSON eagerly and throws the raw body away. Use the fastify-raw-body plugin to keep it.
npm install fastify-raw-body
const Fastify = require('fastify')
const rawBody = require('fastify-raw-body')
const crypto = require('crypto')
const app = Fastify()
app.register(rawBody, {
field: 'rawBody',
global: true,
encoding: 'utf8',
runFirst: true,
})
function verifyBDAPISignature(req, secret) {
const ts = req.headers['x-bdapi-timestamp']
const sig = req.headers['x-bdapi-signature']
if (!ts || !sig || !req.rawBody) return false
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const received = sig.startsWith('sha256=') ? sig.slice(7) : sig
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(received)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
app.post('/webhooks/bdapi', (req, reply) => {
if (!verifyBDAPISignature(req, process.env.BDAPI_WEBHOOK_SECRET)) {
return reply.code(401).send({ error: 'invalid_signature' })
}
console.log('Legit event:', req.body.event)
reply.send({ ok: true })
})
Python with Flask
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BDAPI_WEBHOOK_SECRET"].encode("utf-8")
def verify_bdapi_signature(req) -> bool:
ts = req.headers.get("X-BDAPI-Timestamp")
sig = req.headers.get("X-BDAPI-Signature", "")
raw = req.get_data(cache=True) # bytes, NOT the parsed JSON
if not ts or not sig or not raw:
return False
# Replay window: 5 minutes
if abs(time.time() - int(ts)) > 300:
return False
# Strip the "sha256=" prefix
received = sig[7:] if sig.startswith("sha256=") else sig
message = f"{ts}.".encode("utf-8") + raw
expected = hmac.new(WEBHOOK_SECRET, message, hashlib.sha256).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, received)
@app.post("/webhooks/bdapi")
def webhook():
if not verify_bdapi_signature(request):
abort(401)
payload = request.get_json()
print("Legit event:", payload["event"])
return {"ok": True}
PHP (plain / Laravel)
<?php
$secret = getenv('BDAPI_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_BDAPI_TIMESTAMP'] ?? null;
$sig = $_SERVER['HTTP_X_BDAPI_SIGNATURE'] ?? '';
if (!$ts || !$sig || !$raw) {
http_response_code(401);
exit;
}
// Replay window: 5 minutes
if (abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
// Strip the "sha256=" prefix
$received = str_starts_with($sig, 'sha256=')
? substr($sig, 7)
: $sig;
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
// Constant-time comparison — hash_equals, never ===
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
echo json_encode(['ok' => true]);
In Laravel the pattern is identical wrapped in middleware: $request->getContent() gives you the raw body, $request->header('X-BDAPI-Signature') returns the header. hash_equals() does the heavy lifting.
4. Three mistakes that will silently break verification
If your computed signature does not match what BD-API sent, you are almost certainly hitting one of these.
❌ Mistake 1: Re-stringifying the parsed body
// WRONG — will never match
const body = JSON.stringify(req.body)
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')
When your framework parses JSON, it normalizes whitespace, rewrites Unicode escapes and may reformat numbers. JSON.stringify() on the parsed object is not byte-for-byte identical to what arrived over the wire. The signature will not match. Ever.
Sign over the bytes, not the representation.
❌ Mistake 2: Comparing signatures with ===
// WRONG — timing attack waiting to happen
if (expected === received) { ... }
=== short-circuits on the first differing character. An attacker can measure your server's response time across many candidate signatures and recover your secret one character at a time. Slow, but effective.
Always use constant-time comparison: crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP).
❌ Mistake 3: Ignoring the timestamp
If you only verify the signature and skip the timestamp check, an attacker who captures one legitimate webhook can replay it indefinitely — every replay passes verification. That's a replay attack, and it's trivial to pull off.
The standard guard: reject any request whose timestamp is more than five minutes off your server's clock. BD-API makes this easy because the timestamp is inside the signed message — fudging the timestamp invalidates the signature automatically.
5. What if your verification fails?
Return 401 or 403. BD-API treats any non-2xx response as failure and retries up to three times with exponential backoff (1s, 2s, 4s) before marking the dispatch permanently failed.
Log the attempt. A growing tail of bad-signature requests landing on your endpoint means someone is probing — drop a metric, alert your team, and consider rotating the URL if it gets noisy.
6. Where to start
If you don't have an active BD-API client yet, setup is a couple of minutes: we provision your credentials, you receive the webhook_secret exactly once (do not lose it), you point webhook_url at your endpoint, you're live.
A human replies within 24 business hours. Already integrated and stuck on a signature mismatch? Drop the details in the form and we go straight to the code.