Webhooks · HMAC · Security

Verifying BD-API webhook HMAC signatures — a 5-minute drop-in for Node, Python and PHP

8 min
  • 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_body is the exact bytes your server received — not the parsed JSON, and not JSON.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.

Tell us about your integration →

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.

Request access