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. The CosIng API product page covers the full endpoint surface — versioned diffs, search, and webhooks together — if you want the complete picture before you commit. If all you need is to look up individual ingredients rather than integrate the full API, the CosIng search page does that without any webhook setup.

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.

This article was written with the assistance of artificial intelligence and was reviewed and verified by a person against the official sources (EUR-Lex, CosIng, European Commission). More on our use of AI

← Back to the blog