# Verificación de webhooks

> Versión en Markdown de la documentación pública de la API v1 de Bifrost Crypto, para agentes de IA y generadores de código. El mismo contenido de las páginas HTML, generado de la misma fuente, sin navegación ni estilos.
>
> Versión HTML: https://bifrostcrypto.com/es/docs/webhooks
> Archivos relacionados: [ai.md](https://bifrostcrypto.com/ai/es.md) · [Postman](https://bifrostcrypto.com/postman-collection.json) · [OpenAPI](https://bifrostcrypto.com/openapi.json) · [llms.txt](https://bifrostcrypto.com/llms.txt)
> Otros idiomas: [Português](https://bifrostcrypto.com/ai/pt-BR/webhooks.md) · [English](https://bifrostcrypto.com/ai/en/webhooks.md) · [Français](https://bifrostcrypto.com/ai/fr/webhooks.md)
> API v1 · último cambio de contrato el 2026-10-10

Confirme que el aviso lo envió Bifrost antes de confiar en el cuerpo.

## Headers Enviados en Webhooks

Todos los webhooks se envían con headers especiales para validación e identificación:

```http
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.0
```

X-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:

```text
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

**PHP**

```php
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);
}
```

**Node.js**

```javascript
const crypto = require('crypto');

function verifyBifrostWebhook(rawBody, headers, key) {
  const signature = headers['x-bifrost-signature'];
  const timestamp = headers['x-bifrost-timestamp'];
  const delivery = headers['x-bifrost-delivery'];

  if (!signature || !timestamp || !delivery) return false;

  // Reject stale messages.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
  const canonical = ['v1', timestamp, delivery, bodyHash].join('\n');
  const expected =
    'v1=' + crypto.createHmac('sha256', key).update(canonical).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import hashlib
import hmac
import time


def verify_bifrost_webhook(raw_body: bytes, headers: dict, key: str) -> bool:
    signature = headers.get("X-Bifrost-Signature", "")
    timestamp = headers.get("X-Bifrost-Timestamp", "")
    delivery = headers.get("X-Bifrost-Delivery", "")

    if not signature or not timestamp or not delivery:
        return False

    # Reject stale messages.
    if abs(time.time() - int(timestamp)) > 300:
        return False

    body_hash = hashlib.sha256(raw_body).hexdigest()
    canonical = "\n".join(["v1", timestamp, delivery, body_hash])
    expected = "v1=" + hmac.new(
        key.encode(), canonical.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(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. |

```json
{
  "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 |
