- BD-API firma cada webhook con HMAC-SHA256 sobre
timestamp.cuerpo_crudo(sí, concatenados con un punto literal).- Tres errores tiran abajo la verificación: re-stringificar el body parseado, comparar la firma con
===y no validar el timestamp.- Te dejamos código probado en Node (Express y Fastify), Python y PHP.
1. Por qué importa verificar la firma
Tu endpoint de webhook es público. Internet entero puede enviarle peticiones POST. Sin verificación, no tienes forma de distinguir un evento legítimo de BD-API de uno falsificado por un atacante que descubrió tu URL.
La solución estándar es HMAC con un secreto compartido: BD-API y tu servidor comparten una cadena secreta. BD-API firma cada webhook con ese secreto, tu servidor recalcula la firma con la misma fórmula y, si coinciden, sabes que la petición es legítima. Si no coinciden, la descartas.
Es así de simple, y es así de eficaz. Lo difícil no es la criptografía — es no equivocarse con los detalles.
2. Cómo firma BD-API
Cuando BD-API te envía un webhook, añade tres cabeceras:
X-BDAPI-Event: ec.publication.detected
X-BDAPI-Timestamp: 1716624000
X-BDAPI-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
El cálculo de la firma es:
message = `${X-BDAPI-Timestamp}.${cuerpo_crudo}`
signature = HMAC-SHA256(secreto_compartido, message)
header = `sha256=${hex(signature)}`
Dos puntos críticos que vale repetir:
- El
cuerpo_crudoson los bytes exactos que recibió tu servidor. No el JSON parseado, no el resultado deJSON.stringify()sobre el objeto parseado. Los bytes tal cual llegan por la red. - El timestamp también va dentro del mensaje firmado — así un atacante no puede falsificar un timestamp nuevo sobre un payload capturado sin romper la firma. Aun así, eres tú quien debe rechazar los timestamps antiguos (mira el Error 3): BD-API no valida la ventana de antigüedad por ti.
3. Verificación en 4 entornos
Node.js con Express
El truco está en capturar el body crudo antes de que express.json() lo parsee. El callback verify te lo permite:
const express = require('express')
const crypto = require('crypto')
const app = express()
// Captura el cuerpo crudo en req.rawBody antes de parsear el JSON
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
// Ventana anti-replay: 5 minutos
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// Quitar el prefijo "sha256="
const received = sig.startsWith('sha256=') ? sig.slice(7) : sig
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody.toString('utf8')}`)
.digest('hex')
// Comparación timing-safe (NUNCA uses ===)
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('Evento legítimo:', req.body.event)
res.status(200).send({ ok: true })
})
Node.js con Fastify
Fastify parsea el JSON automáticamente y descarta el body crudo. Para conservarlo, usa el plugin fastify-raw-body:
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('Evento legítimo:', req.body.event)
reply.send({ ok: true })
})
Python con 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, NO el JSON ya parseado
if not ts or not sig or not raw:
return False
# Ventana anti-replay: 5 minutos
if abs(time.time() - int(ts)) > 300:
return False
# Quitar el prefijo "sha256="
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()
# Comparación timing-safe
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("Evento legítimo:", payload["event"])
return {"ok": True}
PHP (sin framework / 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;
}
// Ventana anti-replay: 5 minutos
if (abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
// Quitar el prefijo "sha256="
$received = str_starts_with($sig, 'sha256=')
? substr($sig, 7)
: $sig;
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
// Comparación timing-safe — hash_equals, NUNCA ===
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
echo json_encode(['ok' => true]);
En Laravel, el patrón es idéntico pero envuelto en un middleware: $request->getContent() te devuelve el body crudo, $request->header('X-BDAPI-Signature') la cabecera. La función hash_equals() es la que hace el trabajo.
4. Los tres errores que rompen la verificación
Si tu firma calculada no coincide con la que envía BD-API, casi seguro estás en uno de estos tres casos.
❌ Error 1: Re-stringificar el body parseado
// MAL — no funciona NUNCA
const body = JSON.stringify(req.body)
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')
Cuando tu framework parsea el JSON, normaliza espacios, reescribe el escape de Unicode y puede reformatear números. El resultado de JSON.stringify() sobre el objeto parseado NO es byte por byte igual al body original. La firma siempre fallará.
La regla es: firma sobre los bytes exactos que llegaron por la red, no sobre la representación parseada.
❌ Error 2: Comparar firmas con ===
// MAL — vulnerable a ataques de tiempo
if (expected === received) { ... }
La comparación === cortocircuita en el primer carácter distinto. Un atacante puede medir el tiempo de respuesta de tu servidor con muchas firmas distintas y deducir el secreto carácter a carácter. Es lento pero efectivo. Usa siempre comparación timing-safe: crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP).
❌ Error 3: Ignorar el timestamp
Si solo verificas la firma pero no compruebas el timestamp, un atacante que intercepte un webhook legítimo puede reenviarlo mil veces y todas las repeticiones pasarán la verificación. Esto se llama replay attack y es trivial de explotar.
La protección estándar es rechazar peticiones cuyo timestamp esté a más de 5 minutos del reloj actual. BD-API te lo pone fácil porque firma el timestamp dentro del mensaje — si alguien intenta modificar el timestamp para hacer el replay, la firma falla automáticamente.
5. ¿Y si tu verificación falla?
Devuelve 401 o 403. BD-API interpreta cualquier respuesta no-2xx como fallo y reintenta hasta tres veces con backoff exponencial (1s, 2s, 4s). Después de eso, marca el dispatch como failed permanentemente.
Aprovecha el log: si ves un número creciente de peticiones con firma inválida llegando a tu endpoint, hay alguien probando. Mete una métrica, alerta a tu equipo y considera ofuscar la URL del webhook si el problema persiste.
6. Por dónde empezar
Si todavía no tienes un cliente activo en BD-API, configurarlo lleva un par de minutos: te creamos las credenciales, recibes el webhook_secret una sola vez (no lo pierdas), apuntas el webhook_url a tu endpoint y listo.
Te respondemos en menos de 24 horas laborables. Si ya estás integrado y tienes una duda concreta sobre firmas, dilo en el formulario y vamos directo al grano.