Webhooks · HMAC · Seguridad

Verifica las firmas HMAC de los webhooks de BD-API en menos de 5 minutos

8 min
  • 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_crudo son los bytes exactos que recibió tu servidor. No el JSON parseado, no el resultado de JSON.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.

Cuéntanos tu integración →

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.

Solicitar acceso