# Webhooks

:::note
Un webhook es el mismo flujo de [eventos](/es/guides/units-api/events), empujado a tu servidor. Las rutas de webhooks son solo para personas (un usuario con sesión o un token personal con acceso de gestión); un principal de servicio no puede llamarlas.
:::

Un Workload puede tener **hasta 5 webhooks**. Cada uno tiene una URL `https` en un host público, un filtro `event_types` (una lista, o todos) y un estado: `active`, `failing` o `disabled`. Al crearlo se devuelve `{webhook, secret}`: el secreto de firma, `whsec_` y 43 caracteres, se **muestra una sola vez** (y de nuevo solo con `rotate-secret`). Un webhook nuevo empieza con los eventos posteriores al momento de crearlo. `disabled_reason` es `failing_72h` si se desactivó por fallos repetidos, y `null` en otro caso.

| Ruta | Hace |
|---|---|
| `GET`, `POST .../units/webhooks` | listar, crear |
| `GET`, `PATCH`, `DELETE .../units/webhooks/{webhook_id}` | leer, cambiar, borrar |
| `POST .../units/webhooks/{webhook_id}/rotate-secret` | secreto nuevo; el anterior sigue firmando 24 horas |
| `POST .../units/webhooks/{webhook_id}/test` | envía un `webhook.ping` |
| `POST .../units/webhooks/{webhook_id}/enable` | reactiva un webhook deshabilitado |
| `GET .../units/webhooks/{webhook_id}/deliveries` | intentos, del más reciente al más antiguo: hora, rango de eventos (`from_seq`, `to_seq`), código de estado, latencia, `error`, los primeros 4 KiB de tu respuesta, próximo intento (30 días). `limit` de 1 a 100 (por defecto 50), `before` pagina hacia atrás |
| `GET .../units/webhooks/metrics` | por webhook en las últimas `window_hours` (1 a 720, por defecto 24): `succeeded`, `failed`, `success_rate`, latencia media, p95 y máxima |

`test` responde con el propio intento (estado, latencia, error) y está limitado a 6 por minuto y webhook. Funciona también con un webhook deshabilitado y no mueve el cursor. Cambiar `url` o `event_types` con `PATCH` sigue entregando desde el mismo cursor; `event_types: []` significa todos los tipos.

**curl**

```bash
W="$ZAROZ_API/projects/$PROJECT/service-instances/$SERVICE/units/webhooks"
curl -sS -X POST "$W" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]}'
curl -sS -X POST "$W/<webhook.id de la respuesta de crear>/test" -H "$AUTH"             # sends webhook.ping
```

**TypeScript**

```ts
const W = `${UNITS}/webhooks`;
const hook = await call("POST", W, { url: "https://hooks.example.com/zaroz", event_types: ["unit.exited", "unit.failed"] });
console.log(hook.body.secret);                                    // whsec_...: shown once
await call("POST", `${W}/${hook.body.webhook.id}/test`);                  // sends webhook.ping
```

**Python**

```python
W = f"{UNITS}/webhooks"
_, hook = call("POST", W, {"url": "https://hooks.example.com/zaroz", "event_types": ["unit.exited", "unit.failed"]})
print(hook["secret"])                                             # whsec_...: shown once
call("POST", f"{W}/{hook['webhook']['id']}/test")                            # sends webhook.ping
```


## Qué recibes

Un `POST` de `{webhook_id, delivery_id, events: [...]}`: **como máximo 50 eventos o 256 KiB**, en orden de `seq`, **al menos una vez**. El siguiente lote solo se envía tras tu `2xx` (en 10 segundos), así que la promesa es el orden. Deduplica por `seq`.

Cabeceras: `Zaroz-Signature: v1=<hex>`, `Zaroz-Timestamp` (segundos Unix), `Zaroz-Webhook-Id`, `Zaroz-Delivery-Id`.

## Verificar la firma

La firma es `HMAC-SHA256(secreto, "{timestamp}.{cuerpo en bruto}")` en hexadecimal. Durante una rotación la cabecera lleva **dos** valores `v1=` separados por coma: acepta cualquiera. Rechaza una marca de tiempo a más de 5 minutos. Verifica los **bytes en bruto** que recibiste, no un cuerpo reserializado.

**curl**

```bash
# the signed string is "<Zaroz-Timestamp>.<raw body>"; compare against each v1= value
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WHSEC" -hex
```

**TypeScript**

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

export function verify(secret: string, timestamp: string, rawBody: string, header: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;   // refuse > 5 min off
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return header.split(",").some((part) => {
    const sig = part.trim().replace(/^v1=/, "");
    return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

**Python**

```python
import hashlib, hmac, time

def verify(secret: str, timestamp: str, raw_body: bytes, header: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # refuse more than 5 minutes off
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(p.strip().removeprefix("v1="), expected) for p in header.split(","))
```


## Reintentos y desactivación

Las entregas fallidas se reintentan a los 5 s, 30 s, 2 min, 10 min, 30 min, 1 h y después cada hora. Un webhook que falla durante 72 horas pasa a `disabled` (`failing_72h`) y se avisa a los propietarios de la organización; su cursor se conserva. Tras `enable`, la entrega continúa desde el cursor si los eventos aún se conservan, o desde el evento más antiguo conservado, precedido de un evento `events.gap {from_seq, to_seq}` para que sepas que te perdiste algunos.

## A qué se niega a llamar la plataforma

El destino se resuelve una vez por intento y se comprueba cada dirección: se rechazan loopback, privadas, link-local, CGNAT, multicast, unique-local IPv6 y los rangos propios de la plataforma, En crear o actualizar la respuesta es un rechazo `invalid-field-value` con `address_not_allowed: ...` en `details`; después, al entregar, el registro de entregas muestra `address_not_allowed` como `error`. Los demás valores de `error` son `dns`, `connect`, `timeout`, `http_status`, `redirect_not_followed`, `invalid_url` y `transport`. Un sexto webhook se rechaza con `409 business-rule-violation` (`too_many_webhooks` en `details`). No se siguen redirecciones. Solo se leen los primeros 4 KiB de tu respuesta.
