Webhooks
Un webhook es la forma en que Eventos 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:
Lenguaje
⋮
JSON
{"types": ["order.*"], "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc"}}{"types": ["order.*"], "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc"}}
La respuesta es 201:
Lenguaje
⋮
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_..."}{"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:
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:
- Lee
x-event-timestampy los bytes del cuerpo sin procesar, exactamente como se recibieron, antes de cualquier análisis de JSON. - Calcula
HMAC-SHA256(secret, timestamp + "." + body), dondesecretes la cadenawhsec_...completa en UTF-8, y escríbelo como hexadecimal en minúscula. - Divide
x-event-signaturepor las comas y acepta la entrega cuando cualquier entradav1=sea igual a tu valor, comparando en tiempo constante. - 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:
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):
Lenguaje
⋮
TypeScript
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);
});
}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):
Lenguaje
⋮
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(",")
)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:
Lenguaje
⋮
JSON
{"subscription_id": "sub_...", "signing_secret": "whsec_...", "previous_valid_until": "2026-10-07T12:00:00.000Z"}{"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-idprocesado y responde2xxde inmediato ante uno que ya hayas visto. - Responde rápido: confirma con un
2xxy 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_inflowenGET /stats; reprodúcelos cuando el endpoint esté sano. - Devuelve
2xxsolo cuando tengas el evento a salvo: un2xxtermina los reintentos.
Ver también
- Eventos: publicación, suscripciones, pull y transmisión
- Errores, Autenticación, Límites