11Webhooks

Sección 11

Webhooks

.md

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): 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), 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": {}
}
CampoQué es
idIdentificador de esta entrega. Es su clave de deduplicación (sección 4). Es opaco: no dependa de su forma ni la parsee.
typeEvento del catálogo (sección 5), en recurso.estado y siempre en minúsculas.
createdAlta del evento en segundos unix. No cambia entre reintentos: no es la hora del intento.
environmentlive o test. Cada endpoint se registra para un entorno y solo recibe el suyo.
dataEl contenido del evento. Su forma depende del type y está publicada campo por campo en la sección 5, en La forma de data. Los cinco campos de arriba son los mismos para todos los eventos, y son los que su capa de transporte necesita.
replay_ofSolo 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).

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
ParteQué es
tEl instante de la firma, en segundos unix.
v1HMAC-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ó:

Diagrama
{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

TypeScript
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ónSu respuesta
Firma válida y evento procesado o encolado2xx. Es el único acuse (sección 7).
Firma inválida, cabecera ausente o malformada400, y regístrelo. No procese nada.
Instante fuera de la ventana400. Es un reenvío o un reloj desajustado, no un evento nuevo.
Válida, pero su sistema no puede procesarla ahoraCualquier 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:

ReglaValor
Tolerancia300 segundos
Cómo se comparaSobre 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.

PasoQuiénQué pasa
1 · EmitirUsted, desde la aplicación de AsixtoSe 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 · DesplegarUstedSu receptor conoce los dos y acepta cualquiera de los dos.
3 · RevocarUstedRevoca 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.

ReglaValor
Clave de deduplicaciónEl campo id del sobre
Ventana que garantiza Asixto7 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énRegistrar 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 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) en vez de inventar el nombre.

EventoSignificado
job.succeededEl trabajo terminó bien. data lleva el resultado publicado y el consumo.
job.failedEl trabajo falló. data lleva el código de error del contrato, no una cadena libre.
job.expiredEl trabajo venció su plazo sin cerrarse y su reserva de créditos se liberó.
credits.threshold_reachedEl consumo de la empresa cruzó un umbral de aviso de su saldo. data lleva el porcentaje alcanzado.
credits.exhaustedLa empresa agotó su saldo: las operaciones responderán 402 hasta que recargue.
credits.spending_cap_reachedEl 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).
certification.finishedTerminó 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).
webhook_endpoint.disabledDesactivamos 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.disabledLa 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_requestedEl 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), 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.

CampoTipoQué es
job_idtextoEl trabajo que cerró. El mismo que le devolvió el 202
operationtextoLa operación que lo originó, con el mismo código que sirve GET /v1/jobs/{job_id} (página 17)
statustextosucceeded o failed. Es la segunda mitad del type y el mismo vocabulario de la página 17
credits_chargedentero o nullCréditos liquidados, no reservados: es la cifra que concilia. null si el trabajo no llegó a liquidar
error_codetexto o nullEn job.failed, el código del contrato (página 16), no una cadena libre. null cuando no hubo error
resultobjeto o nullEl resultado publicado de la operación, el mismo que sirve GET /v1/jobs/{job_id}. null si no hay
metadataobjetoSu 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_omittedtrueSolo 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.

CampoQué trae
job_id · operationComo arriba
statusSiempre expired
credits_charged0 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_codeSiempre job_not_completed
metadataSu 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ó.

CampoTipoQué es
usage_percentnúmeroPorcentaje del saldo consumido con el que se cruzó el umbral
used_creditsnúmeroCréditos consumidos en el periodo
total_availablenúmeroCréditos disponibles del periodo
overage_creditsnúmeroConsumo adicional acumulado
overage_spending_capnúmeroEl 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

CampoTipoQué es
run_idtextoLa corrida. Es lo que cita si escala el resultado
servertextoSu servidor MCP, con el identificador que usted le puso al darlo de alta
statustextopassed, failed o inconclusive. El tercero significa «no lo pudimos comprobar», no «su servidor falló» (página 08)
failed_blocking_checkslista de textoLas comprobaciones bloqueantes que fallaron, nombradas como las filas de la página 08 y no con códigos internos. Vacía si no falló ninguna
suspendedbooleanotrue si esa corrida retiró herramientas que ya estaban publicadas, por una regresión
retired_toolsenteroCuántas se retiraron. 0 cuando suspended es false

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

webhook_endpoint.disabled

CampoTipoQué es
endpoint_idtextoEl endpoint que se desactivó. No es el que recibe esta entrega (sección 8)
urltextoEl destino que dejó de recibir
reasontextoSiempre sustained_failures
consecutive_failuresenteroEntregas agotadas desde su último éxito
last_success_attexto o nullLa última entrega exitosa, en ISO 8601. null si nunca hubo ninguna

tool.disabled

CampoTipoQué es
servertextoSu servidor MCP, con el mismo identificador
capabilitytextoLa capacidad cuyas herramientas dejaron de ofrecerse (página 04)
toolslista de textoLas herramientas retiradas, con el nombre con el que se expusieron al agente
reasontextoSiempre sustained_outage
consecutive_failuresenteroFallos consecutivos observados contra su servidor
last_success_attexto o nullLa ú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

CampoTipoQué es
job_idtextoEl turno en el que el agente pidió el relevo
conversation_idtexto o nullSu identificador de conversación, el que envió en la petición. null si el turno no llevaba ninguno
reasontexto o nullEl motivo que dio el agente, recortado a 512 caracteres. null si no dio ninguno
requested_attextoCuá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:

CampoQué trae en una reproducción
idNuevo, derivado del original ({id original}:r{n}). Es lo que su receptor deduplica.
replay_ofEl id del evento original. Es la referencia con la que correlaciona.
dataEl 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 respuestaQué hace el Gateway
2xxEntregada. Fin.
410 GoneFallo 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 404Se 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.
3xxSe 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 sFallo del intento. Se reintenta.

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

IntentoCuándo
1Inmediato
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ónValor
Entregas agotadas desde su último éxito5 (son 35 intentos fallidos contra ese destino: ya no es una ráfaga)
Tiempo sin ninguna entrega exitosa24 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), 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

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