# Webhooks

Recibe eventos firmados, verifícalos y recupera lo que falte.

FITAL te avisa por HTTPS cuando algo cambia. Cada entrega es un `POST` firmado a tu destino: verifica la firma, responde `2xx` rápido y procesa después. Para verlo funcionar en tu máquina, sigue el [inicio rápido](/docs/webhooks/inicio-rapido/).

> **Próximo.** En producción, las entregas se activan junto con la API. En la cuenta de prueba ya funcionan, firmadas y con el mismo calendario de reintentos: ver [Webhooks simulados](/docs/sandbox/cuenta-de-prueba/#webhooks-simulados).

## Requisitos del receptor

| Requisito | Valor |
|---|---|
| Protocolo | HTTPS en el puerto 443, sin usuario ni contraseña en la URL |
| Certificado | Vigente, emitido por una autoridad reconocida y válido para tu dominio. Uno autofirmado o vencido cuenta como fallo |
| Dirección | Tu dominio debe resolver a direcciones IPv4 públicas. Las entregas salen sólo por IPv4 |
| Conexión | Hasta 5 segundos para abrirla |
| Respuesta | 10 segundos para que FITAL reciba tu código de estado |
| Éxito | Cualquier `2xx`. El cuerpo de tu respuesta se ignora y FITAL cierra la conexión al recibir el código: no dependas de seguir escribiendo después |
| Redirecciones | No se siguen: un `3xx` cuenta como fallo |

La URL se valida al darla de alta y otra vez en cada entrega.

## Cómo llega una entrega

Método `POST`, con `Content-Type: application/json` y estos encabezados:

| Encabezado | Contenido |
|---|---|
| `X-Fital-Timestamp` | Segundos unix de este intento. Cambia en cada reintento |
| `X-Fital-Signature` | Una o más firmas `v1=<hex>`, separadas por comas |
| `X-Fital-Event-Id` | Identificador del evento. Sirve para depurar y no va firmado: para deduplicar usa el `id` del cuerpo |
| `X-Fital-Delivery-Id` | Identificador de la entrega a este destino. Es el mismo en sus reintentos |

El cuerpo es el evento:

```json
{
  "id": "evt_5mPqW2xZr8TnC1vH7kLdS4bJ",
  "type": "payment.completed",
  "resource_id": "pay_6nRtY1wKs3QzD8cL5mVhF2gB",
  "api_version": "2026-11-01",
  "created_at": "2026-11-04T18:01:30.000000Z",
  "sequence": 2,
  "data": { "id": "pay_6nRtY1wKs3QzD8cL5mVhF2gB", "status": "completed" }
}
```

`data` cambia según `type`. El [catálogo de eventos](/docs/webhooks/eventos/) trae el cuerpo de cada uno. Verifica la firma antes de interpretar el JSON: ver [Verificar la firma](/docs/webhooks/verificar-firma/).

## Dar de alta un destino

Por API sólo se consulta, con [`GET /v1/webhook-endpoints`](/docs/operaciones/listar-destinos-de-webhook/). El alta, la edición, la baja, la rotación del secreto y la reactivación se hacen en el panel, y `v1` no publica operaciones para eso: un destino decide a dónde sale tu información, y el panel exige sesión, rol y bitácora que una llave no da. En producción requiere rol de propietario o admin y login de hace menos de 5 minutos; en la cuenta de prueba, también rol de desarrollador.

1. **Crear.** En la pantalla Webhooks elige **Agregar webhook**. Escribe la URL y los eventos que quieres recibir; sin elegir ninguno, recibes todos. Cada URL se registra una sola vez por ambiente.
2. **Guardar el secreto.** El panel muestra el secreto de firma de ese destino una sola vez. Guárdalo en tu gestor de secretos.
3. **Verificar.** Implementa [Verificar la firma](/docs/webhooks/verificar-firma/) en tu receptor, con ese secreto.
4. **Probar.** Abre **Ver entregas** y elige **Enviar evento de prueba**. Llega un evento `webhook.test`, con `{"test": true}` en `data`, y el resultado aparece en esa misma lista. Ese evento no entra a [`GET /v1/events`](/docs/operaciones/listar-eventos/).
5. **Activar.** No hay un paso aparte: el destino nace `active` y recibe eventos desde que lo creas. Créalo cuando tu receptor ya esté listo.

## Estados de un destino

| Estado | Significa | Qué puedes hacer |
|---|---|---|
| `active` | Recibe eventos | Probar, reenviar entregas, rotar el secreto y eliminarlo |
| `disabled` | Dejó de recibir eventos tras 3 días seguidos de fallos | Reactivar, rotar el secreto y eliminarlo. El panel rechaza pruebas y reenvíos hasta reactivarlo |
| `paused` | Valor reservado. Hoy ningún flujo lo asigna | Trátalo como un destino que no recibe eventos |

### Reactivar un destino

Una entrega correcta reinicia la cuenta de fallos. Si los fallos siguen durante 3 días, el destino pasa a `disabled` y aparece un aviso crítico en la pantalla Avisos. Mientras esté así no se le envía nada, y lo pendiente queda `skipped`.

Corrige tu receptor y elige **Reactivar** en la pantalla Webhooks. El destino vuelve a `active` y su cuenta de fallos a cero. Lo que no recibió no se envía solo: recupéralo con [`GET /v1/events`](/docs/operaciones/listar-eventos/), o reenvía desde **Ver entregas** las que aparecen ahí.

## Rotar el secreto

En **Ver entregas** elige **Rotar secreto**. Recibes el secreto nuevo una sola vez. Durante 24 horas llegan dos firmas `v1=`, con el secreto nuevo y con el anterior, separadas por comas. Basta que una valide, así que despliega el secreto nuevo en ese plazo.

Sólo existe un secreto anterior: rotar otra vez dentro de ese plazo retira de inmediato el que estaba en gracia.

## Reintentos

Un intento falla con cualquier respuesta que no sea `2xx`, con un plazo vencido, o con un error de conexión, de certificado o de DNS.

Hay un intento inicial más 10 reintentos, 11 en total, con esta espera entre ellos: 30 s, 1 min, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h, 6 h, 12 h. La ventana total es de 21 h 53 min 30 s desde que termina el primer intento, más lo que tarde cada uno. Después la entrega queda `exhausted`.

Un evento tarda de 20 a 120 segundos en salir. Cada destino recibe hasta 5 entregas simultáneas.

## El registro de entregas

**Ver entregas** muestra las últimas entregas de un destino, con sus intentos y su resultado. Desde ahí puedes reenviar una entrega ya terminada.

| Resultado | Estado de la entrega |
|---|---|
| Entregado | Tu receptor respondió `2xx` |
| Falló | Sigue reintentando, o quedó `exhausted` |
| Descartado | Quedó `skipped`, sin entrega |
| Pendiente | Aún no se intenta, o está en curso |

La pantalla Registros de API muestra las peticiones que tu sistema hace a la API, no estas entregas.

## El evento siempre queda guardado

La entrega HTTP tiene dos excepciones. Queda `skipped` si el destino no está activo o si la cola está llena: 2,000 entregas pendientes por destino y 10,000 por empresa. Queda `exhausted` al agotar los reintentos. Por eso consulta [`GET /v1/events`](/docs/operaciones/listar-eventos/) periódicamente:

1. Pide la lista sin `cursor`: empieza en el evento más nuevo.
2. Sigue con `next_cursor` hacia el pasado hasta llegar a un evento que ya procesaste, o hasta que `next_cursor` sea `null`.
3. Procesa lo nuevo y guarda los `id` vistos o la hora del último.
4. En la siguiente consulta, repasa desde 5 minutos antes del último evento visto: dos eventos casi simultáneos pueden confirmarse en otro orden.

No guardes `next_cursor` entre consultas. Sólo camina hacia el pasado y termina en `null`, así que nunca te mostraría un evento nuevo. El cursor que se guarda es el de [`GET /v1/movements`](/docs/paginacion/#movimientos-un-cursor-que-no-termina).

**Variables de estas muestras: simulador público, sin cuenta**

Define esto en tu terminal. Con estos valores las muestras corren tal cual contra el simulador público:

```bash
export FITAL_API="https://widget.fital.xyz/explorar/v1"
export FITAL_KEY="sk_test_sandbox_publica"
```

En la cuenta de prueba o en producción cambia la base, la llave y el secreto, y sustituye los `id` de ejemplo por los tuyos. Ver [Ambientes](/docs/ambientes/).

```bash
# Empieza sin cursor, en el evento más nuevo. CURSOR sólo sirve para seguir esta vuelta hacia atrás: no lo guardes entre consultas.
CURSOR=""
curl -sS -G "$FITAL_API/events" \
  -H "Authorization: Bearer $FITAL_KEY" \
  --data-urlencode "limit=25" \
  ${CURSOR:+--data-urlencode "cursor=$CURSOR"}
```

```js
// Empieza sin cursor, en el evento más nuevo. cursor sólo sirve para seguir esta vuelta hacia atrás: no lo guardes entre consultas.
const cursor = null;
const q = new URLSearchParams({ limit: '25', ...(cursor ? { cursor } : {}) });
const r = await fetch(`${process.env.FITAL_API}/events?${q}`, {
  headers: { Authorization: `Bearer ${process.env.FITAL_KEY}` },
});
const pagina = await r.json();
console.log(r.status, pagina.data.length, pagina.has_more, pagina.next_cursor);
```

```python
import json, os, urllib.error, urllib.parse, urllib.request
# Empieza sin cursor, en el evento más nuevo. cursor sólo sirve para seguir esta vuelta hacia atrás: no lo guardes entre consultas.
cursor = None
q = {"limit": "25", **({"cursor": cursor} if cursor else {})}
pet = urllib.request.Request(os.environ["FITAL_API"] + "/events?" + urllib.parse.urlencode(q),
    headers={"Authorization": "Bearer " + os.environ["FITAL_KEY"]})
try:
    with urllib.request.urlopen(pet) as r:
        pagina = json.load(r)
        print(r.status, len(pagina["data"]), pagina["has_more"], pagina["next_cursor"])
except urllib.error.HTTPError as e:
    print(e.code, e.read().decode())
```

```php
<?php
// Empieza sin cursor, en el evento más nuevo. $cursor sólo sirve para seguir esta vuelta hacia atrás: no lo guardes entre consultas.
$cursor = null;
$q = http_build_query(['limit' => 25] + ($cursor ? ['cursor' => $cursor] : []));
$c = curl_init(getenv('FITAL_API') . '/events?' . $q);
curl_setopt_array($c, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('FITAL_KEY')],
]);
$pagina = json_decode(curl_exec($c), true);
echo count($pagina['data']), ' ', var_export($pagina['has_more'], true), ' ', $pagina['next_cursor'] ?? 'null', "\n";
```

## Duplicados y desorden

La entrega es al menos una vez y sin orden. Deduplica por el `id` del cuerpo, después de verificar la firma: la firma cubre el cuerpo y no los encabezados, así que `X-Fital-Event-Id` se puede cambiar sin invalidarla. Ordena por `sequence`: un pago `completed` puede pasar después a `returned`, y un retroceso real también se publica. Ver [Orden](/docs/webhooks/eventos/#orden).

## Errores comunes

- Verificar sobre el JSON ya interpretado: cambia los bytes y la firma falla. Usa el cuerpo crudo.
- Deduplicar por `X-Fital-Delivery-Id`: otro destino recibe el mismo evento con otro valor. Usa el `id` del cuerpo.
- Deduplicar por `X-Fital-Event-Id`: no va firmado. Quien capture una entrega podría repetirla con otro valor dentro de la ventana de 5 minutos y procesarla dos veces. Usa el `id` del cuerpo.
- Responder `2xx` hasta terminar de procesar: todo tu trabajo tendría que caber en 10 segundos. Guarda el evento, responde y procesa después.
- Guardar `next_cursor` de [`GET /v1/events`](/docs/operaciones/listar-eventos/) para la siguiente consulta: no verías eventos nuevos.
- Confiar en la IP de origen. La firma es la protección principal. Ver [IP de webhooks](/docs/seguridad/ip-webhooks/).

## Qué sigue

[Verificar la firma](/docs/webhooks/verificar-firma/) y [Catálogo de eventos](/docs/webhooks/eventos/).
