# Webhooks

Un webhook es la forma en que [Eventos](/es/products/events/) entrega a un servidor que tú operas: cada evento que coincide con una suscripción llega a tu endpoint HTTPS como un `POST` firmado. Esta guía cubre cómo crear una suscripción de webhook, la solicitud que recibe tu endpoint, la verificación de su firma, cómo funcionan los reintentos y los mensajes muertos, y cómo rotar el secreto de firma sin perder una entrega.

## Qué es una suscripción de webhook

Una suscripción indica qué eventos quiere un receptor y cómo le llegan. Una **suscripción de webhook** tiene una lista de patrones de tipo y un bloque de entrega con `"mode": "webhook"` y tu `url`. A partir de ahí, cada evento publicado en el espacio de trabajo cuyo `type` coincida con uno de los patrones se envía con POST a esa URL, una vez por suscripción.

La entrega es **al menos una vez**. En operación normal cada evento llega una vez; un reintento, una respuesta lenta o una conexión perdida pueden hacer que llegue de nuevo. Por lo tanto, tu receptor debe construirse para aceptar un duplicado sin consecuencias, usando como clave el id del evento.

La URL debe ser `https`, en el puerto 443, sin nombre de usuario ni contraseña, sin fragmento, y con un host público: los nombres privados, de loopback, de enlace local y de uso local se rechazan cuando se crea la suscripción y se verifican de nuevo en cada entrega. Las redirecciones nunca se siguen.

## Crea una

Llama a `POST https://events.inovacc.dev/v1/workspaces/{workspace}/subscriptions` con una clave secreta que tenga el permiso de escritura de suscripciones:

```json
{"types": ["order.*"], "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc"}}
```

La respuesta es `201`:

```json
{"subscription_id": "sub_...", "types": ["order.*"],
 "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc", "secret_ref": "managed"},
 "created_at": "2026-10-06T12:00:00.000Z",
 "signing_secret": "whsec_..."}
```

`signing_secret` es la clave que tu endpoint usa para verificar las entregas. **Se muestra una sola vez, en esta respuesta, y nunca más**: los listados muestran `"secret_ref": "managed"` y nada más. Guárdalo de inmediato en tu administrador de secretos. Si se pierde, rótalo (más abajo). Un espacio de trabajo contiene como máximo 50 suscripciones, cada una con 1 a 20 patrones.

## La solicitud de entrega

Cada entrega es:

```http
POST https://hooks.example.com/inovacc
content-type: application/json
x-event-id: evt_01K...
x-event-type: order.paid
x-event-timestamp: 1791201600
x-event-signature: v1=<64 lowercase hex characters>

{"event_id":"evt_01K...","type":"order.paid", ... }
```

`x-event-timestamp` es la hora Unix en segundos a la que se firmó la entrega. Durante una rotación de secreto, `x-event-signature` lleva una entrada por cada secreto vigente, la más nueva primero, separadas por comas: `v1=<new>,v1=<old>`.

Responde con cualquier estado `2xx` para confirmar la entrega. Cualquier otra cosa (un `4xx`, un `5xx`, ninguna respuesta en 10 segundos, un error de red) es un intento fallido y se reintentará.

## El envoltorio del evento

El cuerpo es el envoltorio del evento, exactamente como lo reciben los suscriptores de todos los modos:

| Campo | Significado |
|---|---|
| `event_id` | `evt_` seguido de 26 caracteres, asignado por Inovacc cuando se publicó el evento. Único por evento. |
| `type` | El tipo del evento, como `order.paid`: palabras en minúscula separadas por puntos, hasta 128 bytes. |
| `source` | La aplicación que publicó el evento, tomada de su credencial. |
| `time` | Cuándo se publicó, RFC 3339 UTC con milisegundos. |
| `tenant` | La organización, la cuenta, el espacio de trabajo y, cuando se conoce, la aplicación a la que pertenece el evento. |
| `idempotency_key` | La clave del publicador; cuando no envió ninguna, el `event_id`. |
| `data` | La carga JSON del publicador: lo que ocurrió, normalmente ids en lugar de objetos completos. |

Lee solo los campos que necesites, e ignora los campos que no conozcas.

## Verifica la firma

Verifica cada entrega **antes** de analizarla o actuar sobre ella:

1. Lee `x-event-timestamp` y los **bytes del cuerpo sin procesar**, exactamente como se recibieron, antes de cualquier análisis de JSON.
2. Calcula `HMAC-SHA256(secret, timestamp + "." + body)`, donde `secret` es la cadena `whsec_...` completa en UTF-8, y escríbelo como hexadecimal en minúscula.
3. Divide `x-event-signature` por las comas y acepta la entrega cuando **cualquier** entrada `v1=` sea igual a tu valor, comparando en tiempo constante.
4. Rechaza una marca de tiempo a más de cinco minutos de tu reloj, para que no se pueda repetir contra ti una entrega antigua.

**Ejemplo resuelto.** Con el secreto `example-key-0001`, la marca de tiempo `1791201600` y el cuerpo `{"hello":"world"}`, el texto firmado es `1791201600.{"hello":"world"}` y la firma son los 64 caracteres hexadecimales de abajo, impresos aquí en dos mitades que unes sin espacio:

```text
05080ab29a6abfb44033966c7837c2a1
889fbf1a909f9cbd12b9dd5d12bb2bf5
```

por lo que el encabezado dice `v1=` seguido de ambas mitades. Ejecuta tu verificador con este ejemplo antes de desplegarlo.

**TypeScript** (Node.js 18 o posterior):

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyInovaccWebhook(
  secret: string,
  timestamp: string,
  signatureHeader: string,
  rawBody: Buffer,
  nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  return signatureHeader.split(",").some((entry) => {
    const part = entry.trim();
    if (!part.startsWith("v1=")) return false;
    const given = Buffer.from(part.slice(3), "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Python** (3.10 o posterior):

```python
import hashlib
import hmac
import time


def verify_inovacc_webhook(secret: str, timestamp: str, signature_header: str,
                           raw_body: bytes, now: int | None = None) -> bool:
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - ts) > 300:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return any(
        part.strip().startswith("v1=")
        and hmac.compare_digest(part.strip()[3:], expected)
        for part in signature_header.split(",")
    )
```

Ambas funciones reciben el cuerpo sin procesar: configura tu framework web para que te entregue los bytes, no un objeto analizado, en esta ruta.

## Reintentos y tiempos de espera

Tu endpoint tiene **10 segundos** para responder. Un intento fallido se reintenta después de 10 segundos, y la espera se duplica en cada fallo adicional hasta 10 minutos. Cuando se alcanza el tope de 5 reintentos, el evento pasa a los mensajes muertos de tu espacio de trabajo. Un fallo afecta solo a la entrega que falló: los demás eventos, y las otras suscripciones del mismo evento, siguen fluyendo.

Como una entrega puede repetirse, un `2xx` que llega después de que tu endpoint ya procesó el evento es inofensivo siempre que deduplices por `x-event-id`.

## Rota el secreto

`POST /v1/workspaces/{workspace}/subscriptions/{id}/rotate-secret` devuelve un secreto nuevo, una sola vez:

```json
{"subscription_id": "sub_...", "signing_secret": "whsec_...", "previous_valid_until": "2026-10-07T12:00:00.000Z"}
```

Hasta `previous_valid_until` (24 horas por defecto), **ambos secretos firman**: cada entrega lleva `v1=<new>,v1=<old>`. Despliega el secreto nuevo en tu receptor dentro de esa ventana; como el verificador acepta cualquier entrada que coincida, no se pierde nada mientras haces el cambio. Rotar de nuevo dentro de la ventana conserva todos los secretos que no han vencido. La rotación se aplica solo a las suscripciones de webhook (de lo contrario `409 wrong_delivery_mode`).

## Mensajes muertos y reproducción

`GET /v1/workspaces/{workspace}/dead?limit=50` lista los mensajes muertos, los más recientes primero, con el evento, `attempts`, `dead_at`, `replay_count` y `last_error`:

| `last_error` | Significado |
|---|---|
| `webhook_status_<code>` | Tu endpoint respondió ese estado, por ejemplo `webhook_status_500`. |
| `webhook_timeout` | Sin respuesta en 10 segundos. |
| `webhook_network` | Falló la conexión. |
| `url_destination_blocked` | La URL ahora apunta a un lugar al que las entregas no pueden ir. |
| `secret_missing` | No se pudo usar el secreto de firma de la suscripción. |

Corrige la causa y luego `POST /v1/workspaces/{workspace}/dead/{event_id}/replay`. La respuesta es `202`, y el mismo envoltorio, con el mismo `event_id`, se entrega de nuevo a las suscripciones que nunca lo recibieron. Los mensajes muertos se conservan 30 días.

## Buenas prácticas

- **Verifica antes de analizar**: calcula la firma sobre los bytes sin procesar y luego analiza.
- **Haz el receptor idempotente**: almacena cada `x-event-id` procesado y responde `2xx` de inmediato ante uno que ya hayas visto.
- **Responde rápido**: confirma con un `2xx` y haz el trabajo lento después, desde tu propio trabajo.
- **Rechaza las marcas de tiempo caducadas** (con más de cinco minutos de diferencia) y mantén sincronizado el reloj de tu servidor.
- **Conserva el secreto en un administrador de secretos** y rótalo cuando se vaya alguien que lo conocía, o según un calendario.
- **Monitorea los mensajes muertos** y `dlq_inflow` en `GET /stats`; reprodúcelos cuando el endpoint esté sano.
- **Devuelve `2xx` solo cuando tengas el evento a salvo**: un `2xx` termina los reintentos.

## Ver también

- [Eventos](/es/products/events/): publicación, suscripciones, pull y transmisión
- [Errores](/es/guides/errors/), [Autenticación](/es/guides/authentication/), [Límites](/es/guides/limits/)
