Verificación de webhooks
Confirme que el aviso lo envió Bifrost antes de confiar en el cuerpo.
"Volamos a través de los nueve reinos llevando noticias instantáneas, mensajeros divinos que informan de cada movimiento a través de los puentes de Bifrost."Huginn & Muninn, Voces del Padre de Todos
Headers Enviados en Webhooks
Todos los webhooks se envían con headers especiales para validación e identificación:
POST /your-webhook-url HTTP/1.1
Host: your-api.example.com
Content-Type: application/json
User-Agent: Bifrost/1.0
X-Bifrost-Webhook-Type: payment_processed
X-Bifrost-Timestamp: 1728138000
X-Bifrost-Delivery: 9f2b1c745ae34d1890c61e7d34b0af52
X-Bifrost-Signature: v1=6f3a...c81d
X-Bifrost-Version: 1.0X-Bifrost-Signature, X-Bifrost-Timestamp y X-Bifrost-Delivery acompañan cada entrega. Son ellos los que prueban el origen del mensaje.
Tipos de webhook
El campo event del cuerpo indica qué ocurrió. Estos son todos los valores que puede tomar:
| event | Descripción |
|---|---|
| payment_processed | Payout completado. data.status es success. X-Bifrost-Webhook-Type: payment_processed. |
| payment_failed | El payout no pudo completarse. data trae solo status (failed), reference_id e internal_reference. X-Bifrost-Webhook-Type: payment_processed. |
| payment_detected | Depósito visto en la red; funds_available es false. X-Bifrost-Webhook-Type: payment_confirmed. |
| payment_confirmed | Depósito final; funds_available es true. X-Bifrost-Webhook-Type: payment_confirmed. |
| payment_reversed | Una reorganización deshizo el depósito y se retiró el crédito. data.status es reversed, y check-payin pasa a devolver status reversed. X-Bifrost-Webhook-Type: payment_confirmed. |
| fixed_address_pre_revoke · fixed_address_revoked | Ciclo de vida de la dirección fija, descrito al final de esta página. X-Bifrost-Webhook-Type: fixed_address_lifecycle. |
X-Bifrost-Webhook-Type solo nombra la familia del aviso: todo evento de depósito, incluidos payment_detected y payment_reversed, llega con payment_confirmed en ese encabezado, y un payout fallido con payment_processed. Decida siempre por el campo event del cuerpo.
Entrega y reintentos
Cada aviso es un POST con cuerpo JSON a la webhook_url que registró. Qué cuenta como entregado, y qué ocurre cuando no lo es:
- Responda con cualquier estado 2xx en menos de 30 segundos (la conexión en sí debe abrirse en menos de 10 segundos). Cualquier otro estado, incluidos 3xx, un timeout o un error de conexión, cuenta como fallo.
- Las redirecciones no se siguen. El certificado TLS se verifica, HTTPS es obligatorio en producción, y la conexión se hace por IPv4 a la dirección validada para su host.
- Una entrega fallida se reintenta unos 5 minutos después, hasta 10 intentos en total. Tras el décimo fallo el aviso queda marcado como failed y puede reenviarse desde el panel.
- Cada intento lleva el mismo cuerpo, un X-Bifrost-Delivery y un X-Bifrost-Timestamp nuevos, y una firma calculada para ellos.
- Por lo tanto, el mismo aviso puede llegarle más de una vez. Deduplique por data.internal_reference junto con event (en los avisos de dirección fija, wallet junto con event).
- Responda primero y procese después: hacer trabajo lento antes de responder es la forma habitual de superar el límite de 30 segundos.
El Sello de Heimdall
Cómo confirmar que el mensaje vino realmente del puente
La dirección que recibe sus webhooks es pública: cualquiera que descubra la URL puede enviarle una petición. Sin verificación, un POST falsificado anunciando un pago aprobado es indistinguible de uno real, y suele convertirse en mercancía entregada sin que el dinero haya entrado.
La clave de firma
Cada cuenta tiene una clave dedicada, generada automáticamente y nunca transmitida. Nosotros usamos nuestra copia para firmar cada entrega; usted obtiene la suya una vez y la guarda. Como no viaja junto con el webhook, una fuga de registros en su entorno no entrega la clave, y eso es lo que hace que la firma pruebe algo.
Obtener la clave
La clave ya existe en su cuenta y está en el panel, en Configuración, en el área de webhooks. Mostrarla exige su segundo factor. Copie el valor a su servidor y guárdelo como guardaría una contraseña de base de datos: en una variable de entorno o en un gestor de secretos, nunca en el código versionado.
El panel también permite cambiar la clave, y el cambio invalida la anterior de inmediato. Entre generar la nueva y actualizar su copia, los webhooks saldrán firmados con una clave que usted aún no tiene, y su verificación fallará en ese intervalo.
Cabeceras de la firma
| Campo | Descripción |
|---|---|
| X-Bifrost-Signature | v1= seguido del HMAC-SHA256 en hexadecimal. |
| X-Bifrost-Timestamp | Marca de tiempo Unix de cuándo se emitió el webhook. |
| X-Bifrost-Delivery | 32 caracteres hexadecimales en minúscula, únicos para este intento de entrega. Cambia en cada reintento. |
La cadena firmada
El HMAC no se calcula sobre el cuerpo crudo, sino sobre esta cadena, unida con saltos de línea entre los campos:
v1
{X-Bifrost-Timestamp}
{X-Bifrost-Delivery}
SHA256_HEX(RAW_BODY)La marca de tiempo y el identificador de entrega entran en el cálculo a propósito. Si solo se firmara el cuerpo, esas dos cabeceras podrían ser reescritas por cualquiera sin invalidar la firma, y un webhook capturado hoy se reenviaría meses después pareciendo recién emitido.
Verificando
function verifyBifrostWebhook(string $rawBody, array $headers, string $key): bool
{
$signature = $headers['X-Bifrost-Signature'] ?? '';
$timestamp = $headers['X-Bifrost-Timestamp'] ?? '';
$delivery = $headers['X-Bifrost-Delivery'] ?? '';
if ($signature === '' || $timestamp === '' || $delivery === '') {
return false;
}
// Reject stale messages. Five minutes is a comfortable margin
// for clock drift between servers.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$canonical = implode("\n", [
'v1',
$timestamp,
$delivery,
hash('sha256', $rawBody),
]);
$expected = 'v1=' . hash_hmac('sha256', $canonical, $key);
// hash_equals, not ==: the comparison must take the same time
// regardless of where the strings diverge.
return hash_equals($expected, $signature);
}Precauciones
- Use el cuerpo crudo. Deserializar el JSON y volver a serializarlo cambia los espacios en blanco y el orden de las claves, y el hash deja de coincidir. Capture los bytes exactamente como llegaron, antes de cualquier parseo.
- Compare en tiempo constante. hash_equals, timingSafeEqual y compare_digest existen para esto: una comparación común de cadenas filtra, por el tiempo de respuesta, cuántos caracteres iniciales son correctos.
- Gestione los reintentos. Una entrega que no recibe 2xx se intenta de nuevo, con un X-Bifrost-Delivery distinto cada vez. El cuerpo no tiene identificador de evento: deduplique por data.internal_reference junto con event, para no procesar dos veces el mismo pago.
- HTTPS es obligatorio en producción. Las URLs http:// se rechazan tanto al registrarlas como al enviarlas.
Webhook sin firma
Si no llega el encabezado X-Bifrost-Signature, no procese el mensaje y contacte al soporte.
Ciclo de vida de la dirección fija
Las direcciones fijas que pasan mucho tiempo sin recibir nada se recuperan. Antes y en el momento en que ocurre, se envía un aviso a la URL de webhook de direcciones fijas configurada en el panel, no a una URL por pago; sin esa URL no se envía ningún aviso. Las direcciones de tipo evm son permanentes y nunca reciben estos eventos. El cuerpo es plano, sin el envoltorio data, y está firmado como cualquier otro webhook.
| event | Descripción |
|---|---|
| fixed_address_pre_revoke | Tras 120 días sin transacción. revoke_at indica cuándo se revocará la dirección (unos 30 días después), salvo que reciba una transacción antes. |
| fixed_address_revoked | Tras 150 días sin transacción. La dirección deja de pertenecer a su cuenta; status es revoked y revoke_at es null. |
{
"event": "fixed_address_pre_revoke",
"wallet": "TQ5n3n1Yd8k9QeXbUuG5mJb4Lw2Rr7Vx9P",
"network": "Tron",
"token": null,
"provider": "internal",
"customer_info": "customer_12345",
"status": "STATUS_ASSIGNED",
"last_transaction_at": "2026-05-01 10:00:00",
"assigned_at": "2026-02-10 09:30:00",
"revoke_at": "2026-09-28 10:00:00",
"message": "Address inactive; it will be revoked soon if no transaction is received.",
"timestamp": "2026-08-29T10:00:00-03:00"
}| Campo | Descripción |
|---|---|
| event | fixed_address_pre_revoke o fixed_address_revoked |
| wallet | La dirección fija |
| network · token | Red y token de la dirección; token puede ser null |
| provider | Reservado. No dependa de él. |
| customer_info | El customer_info indicado al pedir la dirección, o null |
| status | Estado de la dirección en el momento del aviso (STATUS_ASSIGNED en la prerrevocación), o revoked |
| last_transaction_at · assigned_at | Última transacción recibida y fecha de asignación; la inactividad se cuenta desde last_transaction_at, o desde assigned_at cuando la dirección nunca recibió una transacción |
| revoke_at | Fecha prevista de revocación en fixed_address_pre_revoke; null en fixed_address_revoked |
| message | Explicación legible, en inglés |
| timestamp | Cuándo se generó el aviso, en ISO 8601 |