Verificar firmas
Confirma que una petición viene realmente de Bitelio antes de actuar sobre ella
Verifica el cuerpo en bruto, no un objeto vuelto a serializar
La firma se calcula sobre los bytes exactos del cuerpo de la petición que envió Bitelio. Si tu framework analiza (parsea) el JSON antes de que tu handler lo reciba y vuelves a firmar el objeto ya parseado (por ejemplo, JSON.stringify(req.body)), los bytes reserializados casi nunca coinciden con los originales — distinto orden de claves, distintos espacios, un número reformateado — y la verificación falla incluso en peticiones legítimas. Esta es, con diferencia, la causa más común de que la verificación de firmas "no funcione". Captura el cuerpo en bruto antes de que cualquier middleware de parseo de JSON lo toque, y verifica contra eso.
Cabeceras
Cada entrega lleva:
| Cabecera | Qué es |
|---|---|
Bitelio-Signature | Partes v1=<hex> separadas por comas, una por cada secreto de firma actualmente válido. Acepta la petición si cualquiera de las partes verifica — ver más abajo. |
Bitelio-Timestamp | Hora Unix (en segundos) en la que se firmó la petición. Forma parte de la propia cadena firmada, así que puedes rechazar una petición cuya firma sea válida pero cuya marca de tiempo resulte implausiblemente antigua. |
Bitelio-Idempotency-Id | Identifica esta entrega. Se mantiene estable en todos los reintentos de la misma entrega — úsala para deduplicar. |
Bitelio-Event-Type | El nombre del evento, por ejemplo email.delivery o shopify.order.paid. |
Bitelio-Schema-Version | El formato de payload con el que se creó el endpoint. Actualmente siempre v1. |
Las peticiones se envían con Content-Type: application/json.
Cómo se construye la firma
Bitelio calcula un HMAC-SHA256, con clave el secreto de firma de tu endpoint, sobre la cadena:
{timestamp}.{cuerpo de la petición en bruto}— el valor de Bitelio-Timestamp, un punto literal ., y a continuación los bytes exactos del cuerpo JSON — y envía el resultado codificado en hexadecimal. Que la marca de tiempo esté dentro de la cadena firmada (en lugar de ir junto a ella, sin firmar) es lo que te permite rechazar una petición antigua incluso cuando su firma es, por lo demás, válida: nadie puede cambiar la marca de tiempo sin invalidar la firma que la acompañaba.
Por qué Bitelio-Signature puede llevar más de un valor
Bitelio-Signature va separada por comas porque puede haber más de un secreto válido para tu endpoint a la vez, durante una rotación de secreto: cuando rotas, el secreto anterior sigue firmando peticiones junto con el nuevo durante las siguientes 24 horas. Durante esa ventana, cada petición lleva dos partes v1=<hex> — una por secreto — para que puedas cambiar el secreto que tienes guardado por el nuevo a tu propio ritmo, sin que un solo evento deje de verificarse mientras tanto.
Comprueba todas las partes
Acepta la petición si cualquiera de las partes v1= verifica. Un receptor que solo comprueba la primera parte empieza a rechazar aproximadamente la mitad de su tráfico en cuanto arranca una rotación, y sigue haciéndolo hasta que la ventana de rotación se cierra 24 horas después.
Un ejemplo completo (Node.js)
import {createHmac, timingSafeEqual} from 'node:crypto';
import {createServer} from 'node:http';
// El secreto actual siempre es necesario. El anterior solo importa
// mientras hay una rotación en curso (ver "Por qué Bitelio-Signature
// puede llevar más de un valor" arriba) — déjalo sin definir el resto
// del tiempo.
const SECRETS = [
process.env.BITELIO_WEBHOOK_SECRET,
process.env.BITELIO_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
// Bitelio no impone esto — es una decisión del receptor para rechazar
// una firma que es válida pero sospechosamente antigua. Cinco minutos
// es un punto de partida razonable; amplíalo si tu propia
// infraestructura añade latencia antes de que tu handler vea la petición.
const TOLERANCE_SECONDS = 5 * 60;
function isValidSignature(rawBody, timestampHeader, signatureHeader) {
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
if (!signatureHeader) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const parts = signatureHeader.split(',').map(part => part.trim());
return SECRETS.some(secret => {
const expected = Buffer.from(
createHmac('sha256', secret).update(signedPayload).digest('hex'),
'hex',
);
return parts.some(part => {
if (!part.startsWith('v1=')) return false;
const candidate = Buffer.from(part.slice('v1='.length), 'hex');
// timingSafeEqual en lugar de === o .equals(): una comparación
// normal termina en cuanto encuentra el primer byte distinto, lo
// que filtra cuánto de un intento era correcto a través del tiempo
// de respuesta. Ambos buffers deben tener la misma longitud antes
// de llamarla, porque si no lanza una excepción.
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
});
}
const server = createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405).end();
return;
}
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
// Los bytes en bruto, capturados antes de que nada los parsee. Ver
// el aviso al principio de esta página — es la línea que más importa.
const rawBody = Buffer.concat(chunks).toString('utf8');
const ok = isValidSignature(
rawBody,
req.headers['bitelio-timestamp'],
req.headers['bitelio-signature'],
);
if (!ok) {
res.writeHead(401, {'Content-Type': 'text/plain'}).end('invalid signature');
return;
}
const event = JSON.parse(rawBody);
console.log(`received ${event.type} (idempotency id: ${req.headers['bitelio-idempotency-id']})`);
// Haz tu trabajo y confirma rápido — cualquier respuesta fuera del
// rango 200-299 se considera un fallo y se reintenta. Ver "Garantías
// de entrega".
res.writeHead(200).end('ok');
});
});
server.listen(3000, () => console.log('listening on :3000'));Guarda esto como verify-webhook.mjs y ejecútalo con node verify-webhook.mjs — sin dependencias. Node pone en minúsculas los nombres de las cabeceras entrantes, así que req.headers['bitelio-timestamp'] es correcto independientemente de cómo llegara escrita la cabecera.
Si usas un framework en lugar de http a pelo, lo único que hay que preservar es capturar el cuerpo antes de que se parsee. En Express, por ejemplo, monta express.raw({type: 'application/json'}) en esta ruta concreta (no de forma global) para que req.body sea el Buffer sin tocar, y pasa req.body.toString('utf8') como rawBody en el código de arriba.