# 11 · Webhooks

Cómo recibe su sistema los eventos que Asixto le entrega: la forma del sobre, la verificación de la
firma, la deduplicación y qué pasa cuando su receptor no responde.

Es la primera página de la **etapa 2** ([README](./README.md)): aquí la dirección se invierte y el
que expone un endpoint HTTP es usted. No depende de la etapa 1 y no hace falta tener un servidor MCP
para implementarla.

> **Disponibilidad.** Lo que esta página publica es el **contrato de la forma**, y está fijado: la
> cabecera de firma, el mensaje que se firma, la tolerancia, el sobre, los nombres de los eventos, la
> forma de su `data` y la escalera de reintentos. Sirve para escribir y probar su receptor hoy, con sus propios datos de
> prueba. Lo que **todavía no está abierto** es la otra mitad: el alta del endpoint y la
> reproducción manual desde la aplicación de Asixto, y la emisión de los eventos. Hasta que se
> anuncie ([página 09](./09-changelog.md)), un receptor recién montado no va a recibir nada — y no es
> un fallo suyo. Escríbalo ahora si quiere: cuando se abra, esta página no cambia.

---

## 1 · Lo que llega

Un `POST` con `content-type: application/json` a la URL que usted registre, con el sobre completo en
el cuerpo.

```json
{
  "id": "9f1c3a70-4e0b-4f2a-9d5e-2b8c1a6f0d33",
  "type": "job.succeeded",
  "created": 1787923200,
  "environment": "live",
  "data": {}
}
```

| Campo | Qué es |
|---|---|
| `id` | Identificador de **esta entrega**. Es su clave de deduplicación (sección 4). Es **opaco**: no dependa de su forma ni la parsee. |
| `type` | Evento del catálogo (sección 5), en `recurso.estado` y siempre en minúsculas. |
| `created` | Alta del evento en segundos unix. **No cambia entre reintentos**: no es la hora del intento. |
| `environment` | `live` o `test`. Cada endpoint se registra para un entorno y solo recibe el suyo. |
| `data` | El contenido del evento. **Su forma depende del `type`** y está publicada campo por campo en la sección 5, en [La forma de `data`](#la-forma-de-data-evento-por-evento). Los cinco campos de arriba son los mismos para todos los eventos, y son los que su capa de transporte necesita. |
| `replay_of` | **Solo en una reproducción manual** (sección 6). En una entrega normal el campo no viene: no llega como `null`, no llega. |

El sobre **no lleva versión de la superficie**. Hoy hay una sola y un campo con un único valor
posible no le permitiría decidir nada; el día que haya una segunda, la que gobierna el sobre será la
del endpoint y se anunciará como cambio de contrato ([página 09](./09-changelog.md)).

No añada suposiciones sobre campos que no están en esta tabla: lo que no aparece aquí, no forma
parte del contrato.

---

## 2 · Verificar la firma

**DEBE** verificar toda entrega antes de procesarla. Su endpoint está expuesto a internet y el sobre
dispara trabajo en su sistema: sin firma, cualquiera que conozca la URL puede decirle que un trabajo
terminó bien.

Cada entrega lleva una cabecera:

```http
Asixto-Signature: t=1787923200,v1=8f2a…c1
```

| Parte | Qué es |
|---|---|
| `t` | El instante de la firma, en segundos unix. |
| `v1` | HMAC-SHA256 en hexadecimal del mensaje canónico, con el secreto de firma del endpoint como clave. Puede venir **más de uno** (sección 3). |

El mensaje que se firma es el instante, un punto y el cuerpo **exacto** que se transmitió:

```
{t}.{cuerpo crudo}
```

Cuatro reglas que deciden si su verificador funciona:

1. **Firme sobre el cuerpo crudo, no sobre el JSON re-serializado.** Si su framework parsea el
   cuerpo y usted lo vuelve a serializar para verificar, obtiene otros bytes y **ninguna entrega
   válida le va a coincidir**. Es el error número uno. En Express se resuelve con
   `express.raw({ type: 'application/json' })` en esa ruta; en FastAPI, con `await request.body()`.
2. **Use el texto de `t` tal como llega**, sin convertirlo a número y volver a formatearlo. El
   mensaje se compone con esa cadena.
3. **Compare en tiempo constante** (`hmac.compare_digest`, `crypto.timingSafeEqual`). Con `==`, el
   tiempo de respuesta revela cuántos caracteres del hexadecimal acertó quien lo intente, y eso
   convierte falsificar la firma en una búsqueda byte a byte.
4. **Recorra todas las firmas `v1=`** y acepte si coincide **cualquiera**. Comparar solo con la
   primera funciona hoy y le rompe el día que rote el secreto (sección 3).

La cabecera viaja **una sola vez con varias firmas dentro**, nunca repetida. Si su pila le entrega
una lista de valores para `Asixto-Signature`, rechace la entrega en vez de unirlos: una cabecera
repetida no es nuestra.

### Python

```python
import hashlib
import hmac
import time

TOLERANCIA_S = 300


def verificar(cabecera: str | None, cuerpo: bytes, secretos: list[str]) -> bool:
    """`cuerpo` es el cuerpo CRUDO. `secretos` son los que su receptor conoce (uno, o dos al rotar)."""
    if not cabecera:
        return False

    instante: str | None = None
    firmas: list[str] = []
    for parte in cabecera.split(","):
        clave, _, valor = parte.strip().partition("=")
        if clave == "t":
            instante = valor
        elif clave == "v1":
            firmas.append(valor)

    if instante is None or not instante.isdigit() or not firmas:
        return False

    # Ventana SIMÉTRICA: un instante en el futuro también se rechaza.
    if abs(int(time.time()) - int(instante)) > TOLERANCIA_S:
        return False

    mensaje = instante.encode() + b"." + cuerpo
    for secreto in secretos:
        esperada = hmac.new(secreto.encode(), mensaje, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(firma, esperada) for firma in firmas):
            return True
    return False
```

### TypeScript

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

const TOLERANCIA_S = 300;

const igualSegura = (a: string, b: string): boolean => {
  const x = Buffer.from(a, 'utf8');
  const y = Buffer.from(b, 'utf8');
  // La longitud se compara antes: `timingSafeEqual` exige buffers del mismo tamaño.
  return x.length === y.length && timingSafeEqual(x, y);
};

export const verificar = (cabecera: string | undefined, cuerpo: string, secretos: string[]): boolean => {
  if (!cabecera) return false;

  let instante: string | null = null;
  const firmas: string[] = [];

  for (const parte of cabecera.split(',')) {
    const corte = parte.indexOf('=');
    if (corte <= 0) continue;
    const clave = parte.slice(0, corte).trim();
    const valor = parte.slice(corte + 1).trim();
    if (clave === 't') instante = valor;
    if (clave === 'v1' && valor) firmas.push(valor);
  }

  if (instante === null || !/^[1-9][0-9]*$/.test(instante) || firmas.length === 0) return false;

  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - Number(instante)) > TOLERANCIA_S) return false;

  return secretos.some((secreto) => {
    const esperada = createHmac('sha256', secreto).update(`${instante}.${cuerpo}`, 'utf8').digest('hex');
    return firmas.some((firma) => igualSegura(firma, esperada));
  });
};
```

### Qué devolver

| Situación | Su respuesta |
|---|---|
| Firma válida y evento procesado o encolado | **2xx**. Es el único acuse (sección 7). |
| Firma inválida, cabecera ausente o malformada | **400**, y regístrelo. No procese nada. |
| Instante fuera de la ventana | **400**. Es un reenvío o un reloj desajustado, no un evento nuevo. |
| Válida, pero su sistema no puede procesarla ahora | Cualquier código que no sea 2xx: se reintenta (sección 7). Mejor todavía: acuse y procese en su cola. |

---

## 3 · La ventana anti-replay: 300 s, y es simétrica

**La ventana la aplica usted, no nosotros.** Lo único que hace Asixto es firmar un instante; quien
rechaza una entrega capturada y reenviada mañana es su receptor, comparando ese instante con su
reloj. Por eso la cifra es parte del contrato público y no una preferencia:

| Regla | Valor |
|---|---|
| Tolerancia | **300 segundos** |
| Cómo se compara | Sobre la diferencia **absoluta** entre `t` y su reloj |

Son los mismos 300 s que trae por omisión el SDK de Stripe, así que si su equipo ya verifica webhooks
de Stripe no tiene dos ventanas que razonar.

**Lo que sí es distinto: la nuestra es simétrica.** La comprobación habitual (`ahora - t > tolerancia`)
deja pasar cualquier instante en el **futuro**, sin límite. Los relojes derivan en las dos
direcciones y en su entorno no hay NTP garantizado, así que una ventana de un solo lado no acota nada
por arriba. Compare el valor absoluto, como en los ejemplos de la sección 2.

**Si falta:** una entrega capturada —en un log, en un proxy, en una traza— se puede reenviar semanas
después con su firma intacta, y su sistema la aceptará como nueva.

### Rotación del secreto: varias firmas en la misma cabecera

El secreto de firma se rota **sin ventana de caída**, y eso tiene una consecuencia directa en su
código: durante la rotación la cabecera lleva **una firma `v1=` por cada secreto activo**, y basta
que coincida una.

| Paso | Quién | Qué pasa |
|---|---|---|
| 1 · Emitir | Usted, desde la aplicación de Asixto | Se emite un secreto nuevo. **El anterior sigue activo y sigue firmando.** El nuevo se muestra **una sola vez**: no hay ninguna lectura que lo devuelva después. |
| 2 · Desplegar | Usted | Su receptor conoce **los dos** y acepta cualquiera de los dos. |
| 3 · Revocar | Usted | Revoca el viejo. Un endpoint no se queda sin secreto: revocar el último se rechaza. |

Hay sitio para **dos secretos activos** por endpoint, que es lo que hace posible el orden
desplegar-luego-revocar. Un receptor que compare solo con la primera firma queda fuera del contrato
y pierde entregas justo en el paso 2, que es cuando menos lo espera.

El secreto se reconoce por su prefijo `whsec_`, para que quien lo vea en un `.env` o en un log sepa
qué es y un escáner de secretos reconozca el patrón. Trátelo como credencial, no como configuración.

---

## 4 · Deduplicación: `id`, durante 7 días

El mismo evento **puede llegarle más de una vez**. Es la consecuencia inevitable del acuse: si su
`2xx` se pierde en la red, para nosotros ese intento falló y se reintenta.

| Regla | Valor |
|---|---|
| Clave de deduplicación | El campo `id` del sobre |
| Ventana que garantiza Asixto | **7 días**: un mismo `id` no se le entrega dos veces al mismo endpoint dentro de ese plazo |
| Lo que se le pide a su almacén | Registrar el `id` con un TTL de **al menos 7 días** |

Es la misma cifra que ya se le exige en la [garantía 1](./05-guarantees.md) para la idempotencia de
las herramientas, y a propósito: la deduplicación es simétrica —usted descarta por `id`, nosotros no
reemitimos— y dos ventanas distintas dejarían un tramo en el que uno de los dos deja de cumplir sin
que el otro lo sepa.

**Si falta:** un trabajo se procesa dos veces en su sistema porque un acuse se perdió en la red. No
es un caso raro ni hipotético: es el comportamiento normal de cualquier cola de entregas.

### Orden

No programe contra el orden de llegada. Un reintento puede llegar **después** de un evento
posterior, y eso no es un fallo. Use `created` y el estado de su propio sistema para decidir.

Lo que sí se sostiene: a un mismo receptor no se le envían dos entregas a la vez de la misma
conversación. Entre conversaciones distintas van en paralelo.

---

## 5 · Catálogo de eventos

Se suscribe por evento al registrar el endpoint. **El catálogo es cerrado**: un nombre que no esté en
esta tabla se rechaza al dar de alta y al reconfigurar, no al entregar. Si necesita un evento que no
está, escálelo ([página 10](./10-operation.md)) en vez de inventar el nombre.

| Evento | Significado |
|---|---|
| `job.succeeded` | El trabajo terminó bien. `data` lleva el resultado publicado y el consumo. |
| `job.failed` | El trabajo falló. `data` lleva el código de error del contrato, no una cadena libre. |
| `job.expired` | El trabajo venció su plazo sin cerrarse y su reserva de créditos se liberó. |
| `credits.threshold_reached` | El consumo de la empresa cruzó un umbral de aviso de su saldo. `data` lleva el porcentaje alcanzado. |
| `credits.exhausted` | La empresa agotó su saldo: las operaciones responderán `402` hasta que recargue. |
| `credits.spending_cap_reached` | El tope de gasto de su consumo adicional no admite más consumo: las operaciones responderán `402 spending_cap_reached` hasta que lo aumente. **Recargar saldo no lo levanta** —hay que subir el tope—, y eso es lo que lo separa de `credits.exhausted` ([página 16](./16-api-errors.md)). |
| `certification.finished` | Terminó una corrida de certificación de su servidor MCP. `data` lleva el veredicto y las comprobaciones bloqueantes que fallaron, que es lo que decide si sus herramientas salen a producción ([página 08](./08-certification.md)). |
| `webhook_endpoint.disabled` | Desactivamos uno de sus endpoints por fallos sostenidos (sección 8). Lo reciben sus **otros** endpoints activos, que es lo único que puede recibir el aviso de que uno dejó de recibir. |
| `tool.disabled` | La vigilancia dejó de ofrecer al agente las herramientas de una capacidad de su servidor MCP, por caída sostenida. `data` lleva la capacidad, las herramientas retiradas y los fallos consecutivos observados. **Se reactiva sola** cuando su servidor vuelve a responder: no hay evento de vuelta ni hace falta que nos escriba. |
| `conversation.handoff_requested` | El agente pidió pasar la conversación a una persona. `data` lleva `job_id`, `conversation_id`, `reason` (el motivo que dio el agente, recortado) y `requested_at`. Es la vía **fina**: la misma señal viaja dentro del resultado del trabajo, así que si ya recibe `job.succeeded` la tiene sin suscribirse a este evento. |

Dos precisiones que evitan un diagnóstico equivocado:

- **`job.expired` no es `job.failed`.** El trabajo no falló: terminó sin completarse, y su
  reacción es distinta (volver a pedirlo, no diagnosticar un error).
- **Los dos umbrales de saldo caen en el mismo evento.** El porcentaje es un dato de `data`, no un
  tipo de evento: con un evento por umbral, cambiar un umbral sería un cambio incompatible del
  contrato. Y el saldo es el de **su empresa**, el mismo que consume el resto de la plataforma: un
  aviso puede nacer de consumo que no entró por la API del Gateway.

### La forma de `data`, evento por evento

`data` es lo único del sobre que cambia con el `type`. Aquí está campo por campo, así que puede tipar su
receptor y probarlo hoy, sin esperar a recibir una entrega.

Tres reglas que valen para todo el catálogo:

- Los nombres van en **`snake_case`**, igual que el resto de la superficie.
- **Un campo sin valor viaja en `null`, no se omite.** La única excepción son los tres eventos de saldo, y
  está dicha en su tabla.
- **`data` no lleva identificadores internos nuestros**, y la lista de cada evento es cerrada: un campo que
  no esté en estas tablas no forma parte del contrato.
- La única clave que trae contenido SUYO es `metadata`, y solo en los eventos de trabajo: es lo que mandó en
  la petición ([página 15](./15-api-reference.md)), devuelto tal cual.

#### `job.succeeded` y `job.failed`

Los dos comparten forma. Lo que los distingue es el valor de `status`, no la lista de campos: el cierre
publica el estado guardado del trabajo.

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El trabajo que cerró. El mismo que le devolvió el `202` |
| `operation` | texto | La operación que lo originó, con el mismo código que sirve `GET /v1/jobs/{job_id}` ([página 17](./17-jobs.md)) |
| `status` | texto | `succeeded` o `failed`. Es la segunda mitad del `type` y el mismo vocabulario de la [página 17](./17-jobs.md) |
| `credits_charged` | entero o `null` | Créditos **liquidados**, no reservados: es la cifra que concilia. `null` si el trabajo no llegó a liquidar |
| `error_code` | texto o `null` | En `job.failed`, el código del contrato ([página 16](./16-api-errors.md)), no una cadena libre. `null` cuando no hubo error |
| `result` | objeto o `null` | El resultado publicado de la operación, el mismo que sirve `GET /v1/jobs/{job_id}`. `null` si no hay |
| `metadata` | objeto | Su `metadata` de la petición, tal cual. **Ausente** si no mandó ninguno: es la excepción a la regla del `null`, porque una clave vacía no se distingue de un dato que se perdió |
| `metadata_omitted` | `true` | Solo si su `metadata` no cupo en el cuerpo. Ver abajo |

#### `job.expired`

La misma forma **menos `result`**: un trabajo que venció no produjo ninguno, y una clave en `null` le
invitaría a buscar algo que no existe.

| Campo | Qué trae |
|---|---|
| `job_id` · `operation` | Como arriba |
| `status` | Siempre `expired` |
| `credits_charged` | **`0` casi siempre**, porque vencer libera la reserva; y se declara el cero en vez de omitirlo, que se leería como «no se sabe». Lo consultamos al cerrar en vez de darlo por hecho: si una liquidación llegó a cobrar y no logró escribir su cierre, aquí viene **el importe que se le cobró de verdad**, y ese es el que concilia |
| `error_code` | Siempre `job_not_completed` |
| `metadata` | Su `metadata` de la petición, con la misma regla de arriba. Viaja también aquí a propósito: un trabajo que venció es el caso en el que más falta le hace cuadrarlo con su expediente |

**Si su `metadata` no cupiera**, la entrega sale **sin él** y con `metadata_omitted: true` en `data`. Ocurre
solo si el resultado del trabajo ya llena por sí solo los 256 KB del cuerpo, porque su `metadata` está
acotado en 4 KB en la puerta. Lo que se cae es el dato de conciliación, nunca el aviso del cierre: perder el
aviso de un trabajo ya cobrado sería peor, y usted siempre puede releer el resultado con
`GET /v1/jobs/{job_id}`.

#### `credits.threshold_reached`, `credits.exhausted`, `credits.spending_cap_reached`

Los tres llevan **solo cifras**, y son el único caso del catálogo en el que **una clave puede faltar**: viaja
la que el aviso traía medida. No llega en `null`; si no está, no se midió.

| Campo | Tipo | Qué es |
|---|---|---|
| `usage_percent` | número | Porcentaje del saldo consumido con el que se cruzó el umbral |
| `used_credits` | número | Créditos consumidos en el periodo |
| `total_available` | número | Créditos disponibles del periodo |
| `overage_credits` | número | Consumo adicional acumulado |
| `overage_spending_cap` | número | El tope de gasto vigente de ese consumo adicional |

**No lleva qué umbral se cruzó**, y es deliberado: el dato es `usage_percent`, así que mover un umbral no
rompe su código. Tampoco lleva ningún identificador de su balance, que es nuestro.

#### `certification.finished`

| Campo | Tipo | Qué es |
|---|---|---|
| `run_id` | texto | La corrida. Es lo que cita si escala el resultado |
| `server` | texto | Su servidor MCP, con el identificador que usted le puso al darlo de alta |
| `status` | texto | `passed`, `failed` o `inconclusive`. El tercero significa «no lo pudimos comprobar», no «su servidor falló» ([página 08](./08-certification.md)) |
| `failed_blocking_checks` | lista de texto | Las comprobaciones **bloqueantes** que fallaron, nombradas como las filas de la [página 08](./08-certification.md) y no con códigos internos. Vacía si no falló ninguna |
| `suspended` | booleano | `true` si esa corrida retiró herramientas que ya estaban publicadas, por una regresión |
| `retired_tools` | entero | Cuántas se retiraron. `0` cuando `suspended` es `false` |

Llega al endpoint de **`test`**: la batería nunca toca su producción.

#### `webhook_endpoint.disabled`

| Campo | Tipo | Qué es |
|---|---|---|
| `endpoint_id` | texto | El endpoint que se desactivó. **No es el que recibe esta entrega** (sección 8) |
| `url` | texto | El destino que dejó de recibir |
| `reason` | texto | Siempre `sustained_failures` |
| `consecutive_failures` | entero | Entregas agotadas desde su último éxito |
| `last_success_at` | texto o `null` | La última entrega exitosa, en ISO 8601. `null` si nunca hubo ninguna |

#### `tool.disabled`

| Campo | Tipo | Qué es |
|---|---|---|
| `server` | texto | Su servidor MCP, con el mismo identificador |
| `capability` | texto | La capacidad cuyas herramientas dejaron de ofrecerse ([página 04](./04-capabilities.md)) |
| `tools` | lista de texto | Las herramientas retiradas, con el nombre con el que se expusieron al agente |
| `reason` | texto | Siempre `sustained_outage` |
| `consecutive_failures` | entero | Fallos consecutivos observados contra su servidor |
| `last_success_at` | texto o `null` | La última invocación que respondió, en ISO 8601. `null` si nunca respondió |

Llega al endpoint de **`live`**: la vigilancia mide las llamadas reales.

#### `conversation.handoff_requested`

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El turno en el que el agente pidió el relevo |
| `conversation_id` | texto o `null` | **Su** identificador de conversación, el que envió en la petición. `null` si el turno no llevaba ninguno |
| `reason` | texto o `null` | El motivo que dio el agente, recortado a **512 caracteres**. `null` si no dio ninguno |
| `requested_at` | texto | Cuándo se pidió el relevo, en ISO 8601 |

Es la vía fina: la misma señal viaja dentro del `result` de `job.succeeded`, así que si ya lee ese resultado
no necesita suscribirse a este evento para enterarse.

---

## 6 · Reproducción manual

Una entrega ya terminada se puede volver a enviar desde la aplicación de Asixto. Es para el caso real
de toda integración: su receptor respondió `2xx` y su código la procesó mal, o usted arregló el bug
seis horas después de que se agotaran los reintentos. La alternativa —repetir la operación— **se
cobra otra vez**; reproducir no.

Lo que llega es una entrega **nueva**:

| Campo | Qué trae en una reproducción |
|---|---|
| `id` | **Nuevo**, derivado del original (`{id original}:r{n}`). Es lo que su receptor deduplica. |
| `replay_of` | El `id` del evento **original**. Es la referencia con la que correlaciona. |
| `data` | El mismo cuerpo que se envió entonces, sin recomponer. |

**Deduplique por `id`; correlacione por `replay_of`.** Esa separación es todo el mecanismo: si la
reproducción repitiera el `id` original, un receptor bien implementado la descartaría por duplicado
—que es exactamente lo que le pedimos en la sección 4— y el botón no serviría de nada.

No parsee el `id` para extraer el original: use `replay_of`, que es el campo que existe para eso.

Reproducir **no vuelve a ejecutar la operación** y no toca su facturación: reenvía el cuerpo
guardado, tal cual. Y no altera el historial de la entrega original, que se conserva con sus intentos
y su último error.

---

## 7 · Acuse y reintentos

**El único acuse es un 2xx.** Respóndalo en cuanto tenga la entrega a salvo —en su propia cola, en su
base— y procese después. Su tiempo de proceso no debería depender de nada que pueda tardar.

| Su respuesta | Qué hace el Gateway |
|---|---|
| **2xx** | Entregada. Fin. |
| **410 Gone** | **Fallo permanente**: no se reintenta. Es el único código con el que usted declara que ese destino ya no existe. |
| Cualquier otro código, incluidos `400`, `401`, `403` y `404` | **Se reintenta.** Es deliberado: en la práctica un `4xx` de un receptor sano viene de un despliegue a medias, de un WAF o de un balanceador que aún no conoce la ruta, y todos se arreglan solos en minutos. |
| **3xx** | Se trata como fallo y se reintenta. **Las redirecciones no se siguen**: cada salto sería un destino que nadie validó. Registre la URL final, no una que redirija. |
| Sin respuesta en **10 s** | Fallo del intento. Se reintenta. |

La escalera es de **siete intentos** repartidos en unas diez horas:

| Intento | Cuándo |
|---|---|
| 1 | Inmediato |
| 2 | ~1 min después |
| 3 | ~5 min |
| 4 | ~15 min |
| 5 | ~1 h |
| 6 | ~3 h |
| 7 | ~6 h |

Los retardos son aproximados a propósito: cada uno se sortea entre la mitad y el total del valor
nominal. Sin ese sorteo, una caída nuestra sincronizaría a todos los receptores y volverían todos en
el mismo segundo contra sistemas que acaban de levantarse.

Agotada la escalera, la entrega queda **agotada**: no se vuelve a intentar por sí sola, y a partir de
ahí el único camino es la reproducción manual (sección 6). Una entrega agotada cuenta para la
desactivación de la sección 8.

El cuerpo de una entrega no pasa de **256 KB**. No es un límite contra el que deba dimensionar: es un
techo para que un aviso no se convierta en una transferencia.

---

## 8 · Desactivación por fallos sostenidos

Un endpoint que deja de recibir de verdad se **desactiva**, y hacen falta las dos cosas a la vez:

| Condición | Valor |
|---|---|
| Entregas agotadas desde su último éxito | **5** (son 35 intentos fallidos contra ese destino: ya no es una ráfaga) |
| Tiempo sin ninguna entrega exitosa | **24 h** |

La conjunción es la decisión, no un detalle. Solo con la racha, un endpoint muy usado se apagaría por
encajar una ráfaga de minutos durante un despliegue suyo. Solo con la antigüedad, se apagaría un
endpoint que nadie usa: un tenant que no cerró ningún trabajo en un día no tiene éxito reciente
porque no le mandamos nada, no porque esté roto.

Cuando ocurre, sus **otros endpoints activos** reciben `webhook_endpoint.disabled`. Es la razón
práctica para registrar más de uno: un endpoint aparte —el de su equipo de guardia— es lo único que
puede enterarse de que el principal dejó de recibir.

> **La reactivación no es automática.** A diferencia de sus herramientas MCP, que vuelven a ofrecerse
> solas cuando su servidor responde de nuevo ([página 10](./10-operation.md)), un endpoint de webhook
> desactivado **no se reactiva por recibir tráfico**: no le llega ninguno. Arregle su receptor y
> pídalo por el canal de la página 10.

---

## 9 · El endpoint

| Dato | Exigencia |
|---|---|
| URL de destino | **DEBE** ser un destino público alcanzable desde internet. Se valida al darla de alta **y otra vez en cada entrega**: un nombre que resuelva a una dirección interna, a `localhost` o al servicio de metadatos de una nube se rechaza. **DEBERÍA** ser HTTPS: el sobre lleva datos de su operación. |
| Entorno | **DEBE** declararse: `live` o `test`. Un endpoint solo recibe los eventos de su entorno, así que su entorno de pruebas puede tener su propio receptor y su propio secreto. |
| Eventos | **DEBE** ser una lista del catálogo de la sección 5. |
| Cuántos | **PUEDE** registrar varios. Cada uno con su secreto propio: revocar uno no le obliga a rotar los demás. |

El secreto de firma se muestra **una sola vez**, al dar de alta el endpoint y al rotar. Si lo pierde,
el camino es rotar: se emite otro sin dejar de firmar con el anterior (sección 3).

---

## Lista de comprobación de su receptor

| # | Comprobación |
|---|---|
| 1 | Verifica la firma sobre el **cuerpo crudo**, antes de parsear el JSON |
| 2 | Recorre **todas** las firmas `v1=` y admite **dos** secretos a la vez |
| 3 | Compara los HMAC en **tiempo constante** |
| 4 | Rechaza el instante fuera de ±300 s, con diferencia **absoluta** |
| 5 | Rechaza la cabecera ausente, repetida o malformada con `400` |
| 6 | Deduplica por `id` con un TTL de al menos **7 días** |
| 7 | Correlaciona una reproducción por `replay_of`, sin parsear el `id` |
| 8 | Responde **2xx** antes de procesar, y procesa en su propia cola |
| 9 | No depende del orden de llegada: usa `created` |
| 10 | Registra el `id` y el `type` de cada entrega, aceptada o rechazada, para poder cerrar un incidente citándolos |
