Sección 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): 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.
{
"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. 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).
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:
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:
- 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, conawait request.body(). - Use el texto de
ttal como llega, sin convertirlo a número y volver a formatearlo. El mensaje se compone con esa cadena. - 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. - 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
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 FalseTypeScript
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 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.
| 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). |
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). |
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.expiredno esjob.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. datano 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.
| 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) |
status | texto | succeeded o failed. Es la segunda mitad del type y el mismo vocabulario de la página 17 |
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), 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) |
failed_blocking_checks | lista de texto | Las 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 |
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) |
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), 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 |