<!-- Source: https://docs.infraio.xyz/es/webhooks/overview -->
<!-- Last updated: 2026-10-04 -->

# Webhooks — Resumen

Los webhooks son la señal **autoritativa**. Los callbacks del
navegador (`onSuccess`) y las vistas del dashboard son conveniencia;
los webhooks son la verdad de base.

## Garantías de entrega

- **Al menos una vez.** Un solo evento puede entregarse hasta
  **6 veces** si tu servidor no devuelve 2xx dentro del timeout.
  Haz tu handler idempotente: deduplica por `X-Delivery` (el payload
  no tiene un campo `event_id`; el UUID estable de la entrega es la
  clave de idempotencia).
- **Un evento por petición HTTP.** Sin batching.
- **Aislamiento por endpoint.** Si tienes múltiples endpoints
  registrados, cada uno obtiene su propia pista de entrega y
  reintento. Un endpoint lento no retrasa a los otros.
- **Firmado.** Cada payload lleva una cabecera `X-Signature` (y
  durante la ventana de 24 horas tras una rotación, también una
  `X-Signature-Prev`). Verifica antes de hacer nada con el body.
  Consulta [Verificación de firma](https://docs.infraio.xyz/es/webhooks/signature-verification).

## Tipos de evento suscribibles

| Evento | Se dispara cuando… |
| --- | --- |
| `payment.settled` | La transferencia on-chain alcanzó el conteo de confirmaciones de la cadena. **Usa esto para marcar órdenes como pagadas.** |
| `payment.failed` | Un pago fiat fue rechazado por el proveedor de pagos. No se dispara para timeouts cripto: esos aparecen como `checkout.expired`, y los pagos cripto cortos aparecen como `payment.underpaid`. |
| `payment.underpaid` | Los fondos llegaron pero por debajo del total de la orden (típico: tarifa de transferencia de stablecoin tomada del importe). |
| `payment.overpaid` | Los fondos llegaron en exceso del total de la orden. El sobrante se registra pero no se auto-reembolsa. |
| `order.created` | Se abrió una nueva orden: o por tu llamada a la API B2B o por una conversión de sesión de checkout. |
| `order.canceled` | Una orden se movió a cancelada. El `data.reason` del payload distingue cancelación manual de `payment_timeout` (una orden no pagada expiró). |
| `order.resolved` | Una orden `PARTIAL_PAID` se resolvió a `PAID`: el comerciante aceptó el déficit. |
| `order.reopened` | Una orden previamente autocancelada (`canceled_reason=payment_timeout`) fue reabierta por el comerciante. |
| `checkout.created` | Un comprador abrió el checkout para una orden. |
| `checkout.completed` | El flujo del lado comprador terminó (no implica liquidación on-chain: usa `payment.settled` para eso). |
| `checkout.expired` | El comprador abandonó y el TTL de la sesión se agotó. |
| `payment.refund.requested` | Se creó un registro de reembolso: o desde una llamada a la API iniciada por el comerciante o desde un formulario de solicitud de reembolso enviado por el cliente. |
| `payment.refund.approved` | Un reembolso pendiente pasó tu flujo de aprobación. |
| `payment.refund.rejected` | Un reembolso pendiente fue denegado. |
| `payment.refund.executed` | La transferencia on-chain del reembolso se confirmó y el registro se movió a `executed` terminal. |
| `refund_request.created` | Se emitió un token de solicitud de reembolso. `data.source` es `b2b` / `dashboard` / `renewal`. Opcional para suscribirse: útil para pipelines de auditoría que rastrean qué token está actualmente activo por orden. |
| `refund_request.renewal_requested` | Un comprador hizo clic en "Solicitar nuevo enlace" tras expirar su token. **Fuertemente recomendado para suscribirse**: esta es la señal para el comerciante de que el widget de renovaciones tiene un nuevo item sobre el que actuar. |
| `refund_request.renewed` | Una renovación fue aprobada y un nuevo token reemplazó al antiguo. `data.old_token` / `data.new_token` forman la cadena de auditoría. |
| `refund_request.canceled` | Un comerciante cambió un token a `CANCELED` desde el dashboard (p. ej. denegó una solicitud de renovación, mató un enlace activo). Idempotente: solo la primera transición emite. `data.reason` es la nota opcional del comerciante. |

### Planificados (próximamente)

> **Note:**
>
> **Próximamente.** Estos eventos pertenecen a las facturas recurrentes y las
> suscripciones, que aún no están disponibles. **No** están en la tabla de
> eventos suscribibles de arriba y hoy no se pueden suscribir. Ver
> [Facturas recurrentes](https://docs.infraio.xyz/es/guides/recurring-invoices).

| Evento planificado | Se dispara cuando… |
| --- | --- |
| `subscription.created` | Se crea una suscripción. |
| `invoice.created` | Se crea una factura para un ciclo de facturación. |
| `invoice.paid` | Se paga una factura. |
| `subscription.past_due` | Una factura sigue impaga pasada su fecha de vencimiento. |
| `subscription.canceled` | Se cancela una suscripción. |

El formulario de endpoints del dashboard lista los mismos eventos.
Suscribirse a un evento que no existe se rechaza al guardar el
endpoint.

> **Note:**
>
> **Los eventos de prueba no son suscribibles.** El botón
> **Enviar prueba** por endpoint del dashboard envía un evento
> `webhook.test.ping` a ese único endpoint de inmediato, sin
> reintentos. No aparece en el catálogo de arriba: lo recibes por
> tener un endpoint registrado, no por suscribirte.

> **Note:**
>
> Suscríbete solo a los eventos que manejas. Cada endpoint tiene su
> propio filtro de eventos; el wildcard `"*"` significa "cada
> evento, incluyendo los añadidos en el futuro". Suscribirse a menos
> eventos mantiene tu handler más simple y significa menos reintentos
> cuando tu endpoint tiene errores.

## Payload + cabeceras

**El body HTTP es el objeto data específico del evento directamente.**
Sin envelope externo estilo Stripe: campos como el tipo de evento,
delivery ID y timestamp de emisión viven en **cabeceras** en su
lugar. Para `payment.settled` el body se ve así:

```json
{
  "receipt_id":        "rcp_…",
  "order_id":          "ord_…",
  "payment_intent_id": "pin_…",
  "checkout_session_id": "cst_…",
  "merchant_id":       "mer_…",
  "customer_id":       "cus_…",
  "total":             "49.00",
  "currency":          "USD",
  "payment_method":    "crypto",
  "token":             "USDC",
  "network":           "polygon",
  "tx_hash":           "0x…",
  "deposit_address":   "0x…",
  "treasury_address":  "0x…",
  "amount_received":   "49.00",
  "confirmations":     5,
  "metadata":          { /* per-event */ }
}
```

Otros eventos llevan sus propios campos. Los nombres
de campo son estables (lower snake_case); el tx hash on-chain es
siempre `tx_hash`.

`tx_hash` es el identificador de la transacción en el formato propio de la red (`0x…` en cadenas EVM; el hash o la firma nativos en TRON, Solana y TON). `deposit_address` puede estar ausente en TRON, Solana y TON, porque allí los compradores pagan directamente a tu wallet de Tesorería, y `confirmations` sigue [Cadenas y activos](https://docs.infraio.xyz/es/concepts/chains).

### Cabeceras en la petición entrante

```http
Content-Type:      application/json
X-Event:           payment.settled
X-Delivery:        7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key:   7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp:       1729536000
X-Signature:       sha256=9a8b7c…
X-Signature-Prev:  sha256=fa31b2…    (solo durante la ventana de gracia de rotación)
```

| Cabecera | Qué es |
| --- | --- |
| `X-Event` | El tipo de evento (p. ej. `payment.settled`). Enruta sobre esto en la capa proxy si quieres saltarte el parsing JSON. |
| `X-Delivery` | UUID que identifica la fila de entrega. **Estable a través de todos los reintentos** del mismo par `(evento, endpoint)`: úsalo como tu clave de idempotencia. |
| `Idempotency-Key` | Refleja `X-Delivery` (mismo valor). Se establece en cada entrega. |
| `X-Timestamp` | Unix-segundos cuando se envió el intento. Firmado en el payload para que un par `(body, X-Signature)` capturado no pueda repetirse indefinidamente: rechaza entregas cuyo timestamp esté fuera de tu ventana de tolerancia. |
| `X-Signature` | `sha256=<hex>` de `HMAC-SHA256(secret, X-Timestamp + "." + raw_body)`. Consulta [Verificación de firma](https://docs.infraio.xyz/es/webhooks/signature-verification). |
| `X-Signature-Prev` | Mismo algoritmo con el secreto **anterior**. Presente solo en la ventana de 24 horas tras rotar: deja que los verificadores corriendo cualquiera de las claves sigan aceptando entregas durante el corte. Tras cerrar la ventana, la cabecera deja de enviarse. |

## Calendario de reintentos

Si tu endpoint no devuelve `2xx` dentro del timeout, reintentamos
en este calendario (timestamps relativos al primer intento):

| Intento | Retraso | Acumulado |
| --- | --- | --- |
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hora | 1h 21m |
| 6 | +6 horas | 7h 21m |

Tras fallar el intento 6, la entrega se marca como **Failed** y
se notifica al email de tu cuenta. Puedes reproducir los eventos
fallidos desde el panel **Developers →
Webhooks → Delivery history** del dashboard. Cada replay es una
nueva entrega con su propio `X-Delivery`.

## Registra un endpoint

Desde el [dashboard del comerciante](https://app.infraio.xyz):

1. **Developers → Webhooks** → **+ Add endpoint**
2. Pega tu URL: solo `https://…` (HTTP plano se rechaza; el
   formulario de creación también bloquea `localhost`, rangos de
   IP privadas y URLs que lleven userinfo)
3. Elige eventos a los que suscribirte (o `*` para todos)
4. Elige entorno: **test** o **live** (cada uno tiene su propio
   secreto; nunca se cruzan)
5. Guarda → el dashboard muestra el secreto de firma (`whsec_…`)
   **una vez**. Guárdalo en el lado del servidor; lo necesitarás
   para las próximas dos funciones.

Puedes registrar hasta **10 endpoints por entorno por comerciante**
(p. ej., uno para fulfillment de producción, uno para staging
mirroring, uno para un notificador de Slack). Cada uno tiene su propio estado de reintento y secreto.

## Acciones de ciclo de vida en cada endpoint

El menú ⋮ en cada tarjeta de endpoint muestra:

- **Edit**: cambia la URL, descripción o lista de suscripciones.
  La nueva URL se revalida con las mismas reglas de `https://`/SSRF
  que en la creación.
- **Send Test**: POSTea síncronamente un envelope
  `webhook.test.ping` firmado con tu secreto actual. El dashboard
  muestra el status HTTP, latencia y un fragmento de 512 bytes de
  tu respuesta. Los pings de prueba no se reintentan, así que la
  respuesta es inmediata.
- **Rotate Secret**: genera un nuevo secreto. El anterior sigue
  válido por **24 horas** (las entregas llevan tanto `X-Signature`
  como `X-Signature-Prev` durante la ventana para que los
  verificadores corriendo cualquiera de las claves sigan aceptando
  eventos mientras redespliegas).
- **Reveal Secret**: muestra de nuevo el secreto existente.
  Protegido por verificación 2FA reciente y registrado en el log
  de auditoría; úsalo solo cuando hayas perdido tu copia y Rotate
  no sea aceptable.
- **Enable / Disable**: activa o desactiva el endpoint sin perder el historial
  de entregas. Los endpoints deshabilitados permanecen en el
  dashboard pero no reciben nuevas entregas.
- **Delete**: permanente. Usa Disable si podrías re-habilitar más
  tarde.

## Consejos para handlers

1. **Devuelve 2xx rápido.** Confirma con `200 OK` antes de hacer
   trabajo pesado: pasa el fulfillment a un job en background.
   El timeout por intento es de **10 segundos**; retener la
   respuesta más allá de eso dispara un reintento. El timeout es
   del lado de la plataforma y no es configurable por comerciante:
   contacta con soporte si tu handler genuinamente necesita más
   tiempo.
2. **Deduplica por `X-Delivery`** (o `Idempotency-Key`: mismo
   valor). Aunque devuelvas 2xx, un proxy aguas arriba podría
   soltar la conexión y disparar un reintento; el delivery ID es
   estable a través de cada reintento de la misma fila de entrega,
   así que es la clave correcta.
3. **Tolera tipos de evento desconocidos.** Pueden aparecer nuevos
   eventos; devuelve 200 y no-op en lugar de 4xx, o esas entregas
   seguirán reintentándose.
4. **Loguea `X-Delivery` junto a tu lógica de negocio.** Cuando
   algo va mal, esa es la clave de join entre nuestro lado y el
   tuyo.

## Qué sigue

- [Verificación de firma](https://docs.infraio.xyz/es/webhooks/signature-verification): el
  algoritmo exacto + patrones de protección contra replay.
- [Conceptos → Sesiones](https://docs.infraio.xyz/es/concepts/sessions): en qué estado
  está una sesión cuando cada evento se dispara.
