Webhooks
Un aviso firmado cuando una sincronización termina o un enlace de conexión se usa, sin sondear. El webhook nunca es load-bearing: el estado real siempre se puede consultar.
Connect entrega eventos por HTTP POST a los endpoints que registres: cada sincronización que termina y cada enlace de conexión que se consume. La alternativa es sondear GET /v1/syncs/{id}, que funciona igual de bien, solo que pagando la espera con llamadas tuyas.
Una regla gobierna todo el diseño: el webhook nunca es load-bearing. Si tu endpoint está caído, la sincronización igual terminó y la conexión igual quedó creada; la entrega se reintenta sola y el estado real vive siempre en la API. Trata cada evento como un aviso de que hay algo que mirar, nunca como la fuente del dato.
Eventos
| Evento | Cuándo dispara |
|---|---|
sync.succeeded | Un trabajo de sincronización terminó bien. Incluye los resultados parciales: el detalle por alcance viaja en outcomes. |
sync.failed | El trabajo terminó en fallo terminal: un error no reintentable, o se agotaron los reintentos internos. |
sync.partial | Reservado. Es suscribible, pero el pipeline actual no lo emite: un parcial llega como sync.succeeded con el detalle en outcomes. |
connect_session.consumed | Un enlace de conexión se usó con éxito y dejó una conexión creada o reautenticada. |
Cuerpo de un sync.succeeded:
{
"type": "sync.succeeded",
"timestamp": "2026-08-07T09:00:12.000Z",
"data": {
"job_id": "sjb_k2Rw81QpLm3N",
"connection": { "id": "conn_9tKfR2mQx4Vb", "connector": "sii", "label": "Comercial Aurora SpA" },
"alcances": ["rcv", "boletas"],
"periodo": "2026-07",
"status": "succeeded",
"records_synced": 214,
"last_error": null,
"outcomes": [
{ "alcance": "rcv", "status": "ok", "records_synced": 180 },
{ "alcance": "boletas", "status": "ok", "records_synced": 34 }
],
"trigger": "scheduled",
"request_id": "req_..."
}
}Un trabajo donde un alcance falló y otro no sigue siendo sync.succeeded: el trabajo hizo progreso real. La mezcla se lee en outcomes, donde cada alcance declara su propio status (ok, partial o failed) y, cuando falló, un error con un código del catálogo:
"outcomes": [
{ "alcance": "rcv", "status": "ok", "records_synced": 180 },
{ "alcance": "boletas", "status": "failed", "records_synced": 0, "error": "timeout" }
]Algunos conectores agregan a un outcome los campos incompletos (cuántos registros quedaron a medio traer) y completo (si el período quedó cerrado); solo aparecen cuando el conector los reporta.
El sync.failed tiene la misma forma, con records_synced en null, outcomes vacío y el código del error en last_error:
{
"type": "sync.failed",
"timestamp": "2026-08-07T09:05:30.000Z",
"data": {
"job_id": "sjb_...",
"connection": { "id": "conn_9tKfR2mQx4Vb", "connector": "sii", "label": "Comercial Aurora SpA" },
"alcances": ["rcv", "boletas"],
"periodo": "2026-07",
"status": "failed",
"records_synced": null,
"last_error": "upstream_error",
"outcomes": [],
"trigger": "scheduled",
"request_id": "req_..."
}
}Tanto last_error como el error de cada outcome llevan códigos del catálogo, nunca texto libre: el payload de un webhook jamás incluye documentos sincronizados, campos de credencial ni mensajes crudos del sistema externo. El request_id es el mismo que quedó en la bitácora, así que correlacionar el evento con su ejecución es una búsqueda exacta.
El cuarto evento avisa que un enlace hosted se completó:
{
"type": "connect_session.consumed",
"timestamp": "2026-07-28T12:00:00.000Z",
"data": {
"session": { "id": "cs_7f3ab9c1d2e4f5061728" },
"connection": { "id": "conn_9tKfR2mQx4Vb", "connector": "sii" },
"verified": true,
"consumed_at": "2026-07-28T12:00:00.000Z"
}
}verified admite tres valores: true cuando la verificación contra la fuente corrió y pasó; false cuando corrió y falló, aunque la conexión existe igual con la credencial entregada; null cuando el conector no declara una tool de verificación y no había nada que correr. El payload identifica qué enlace se usó y qué conexión resultó, y nada más: nunca el token del enlace ni ningún campo de lo que la persona escribió.
Crear un endpoint
curl -X POST https://connect.emisso.ai/api/v1/webhooks \
-H "Authorization: Bearer connect_sk_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.tu-producto.cl/hooks/connect",
"event_types": ["sync.succeeded", "sync.failed"],
"connection_id": "conn_9tKfR2mQx4Vb"
}'Respuesta (201):
{
"data": {
"id": "whk_...",
"url": "https://api.tu-producto.cl/hooks/connect",
"event_types": ["sync.succeeded", "sync.failed"],
"connection_id": "conn_9tKfR2mQx4Vb",
"enabled": true,
"disabled_at": null,
"last_success_at": null,
"created_at": "2026-08-07T12:00:00.000Z",
"secret": "whsec_..."
},
"meta": { "request_id": "req_..." }
}El secreto aparece una sola vez
secret se devuelve únicamente al crear el endpoint (y al rotarlo). Se guarda cifrado y ninguna lectura posterior lo incluye: GET /v1/webhooks devuelve el endpoint sin él. Si lo pierdes, rota el secreto.
Reglas del alta:
event_typesvacío u omitido suscribe a todos los eventos, presentes y futuros. Listar tipos explícitos congela la suscripción a esos.connection_ides opcional y acota el endpoint a los eventos de esa conexión. Debe ser una conexión de tu organización.- La
urldebe serhttpsy resolver a una dirección pública. Direcciones internas o no resolubles se rechazan con422 invalid_webhook_url. - Un endpoint solo recibe eventos posteriores a su creación; registrarlo no re-entrega historia.
- Los webhooks son una función de plan: sin ella el alta responde
403 feature_not_in_plan.
El mismo alta existe sin código en el dashboard, en connect.emisso.ai/webhooks. Para editar por API: PATCH /v1/webhooks/{id} acepta url, event_types y enabled; DELETE /v1/webhooks/{id} deshabilita (nunca borra: las entregas históricas se conservan).
Verificar la firma
Toda entrega llega firmada con el esquema Standard Webhooks. Tres headers acompañan el cuerpo:
| Header | Contenido |
|---|---|
webhook-id | El id del evento (whev_...). Es estable entre reintentos y entre endpoints: úsalo como clave de deduplicación. |
webhook-timestamp | El instante del intento de entrega, en segundos Unix. Cada reintento se firma de nuevo con el suyo. |
webhook-signature | Una o más firmas separadas por espacio, cada una con la forma v1,<base64>. Hay más de una solo durante una rotación de secreto. |
Lo que se firma es la cadena id.timestamp.body, donde body son los bytes exactos del cuerpo recibido. El algoritmo: HMAC-SHA256, con clave igual al secreto sin su prefijo whsec_ decodificado de base64; el digest viaja en base64 con el prefijo de versión v1,. Este verificador espeja el del servidor:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(input: {
secret: string; // whsec_... tal como lo entregó POST /v1/webhooks
id: string; // header webhook-id
timestamp: number; // header webhook-timestamp, en segundos
rawBody: string; // el cuerpo crudo recibido, sin parsear ni re-serializar
header: string; // header webhook-signature
}): boolean {
// 1. Tolerancia de reloj: 300 segundos. Como cada reintento trae su propio
// timestamp, un reintento tardío nunca queda fuera de la ventana.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - input.timestamp) > 300) return false;
// 2. La clave HMAC es el secreto sin el prefijo whsec_, decodificado de base64.
const key = Buffer.from(input.secret.slice("whsec_".length), "base64");
// 3. Se firma `${id}.${timestamp}.${body}`; el digest va en base64 tras "v1,".
const expected = Buffer.from(
`v1,${createHmac("sha256", key).update(`${input.id}.${input.timestamp}.${input.rawBody}`).digest("base64")}`,
);
// 4. El header puede traer varias firmas separadas por espacio (rotación de
// secreto): la entrega es válida si cualquiera calza, en tiempo constante.
return input.header.split(" ").some((candidate) => {
const c = Buffer.from(candidate);
return c.length === expected.length && timingSafeEqual(c, expected);
});
}Verifica sobre los bytes crudos
El cuerpo se envía como JSON canónico: claves ordenadas alfabéticamente, sin espacios. La firma cubre exactamente esos bytes. Si haces JSON.parse y vuelves a serializar antes de verificar, los bytes cambian y la firma deja de calzar. Lee el cuerpo crudo, verifica, y recién entonces parsea. Los ejemplos de esta página van indentados solo para leerse.
Reintentos y reenvío
Una entrega cuenta como exitosa cuando tu endpoint responde 2xx dentro de 10 segundos. Una redirección no se sigue y cuenta como rechazo. Ante un fallo, la política distingue dos clases:
- Se reintenta lo que puede sanar solo: errores de red, respuestas
5xx,408y429. - Es terminal cualquier otro
4xx: tu endpoint está rechazando el evento y volver a golpear no lo arregla.
Los reintentos son hasta 6 intentos en total, con esperas crecientes tras cada fallo: 1 minuto, 5 minutos, 30 minutos, 2 horas y 6 horas. El bus de entregas corre cada minuto, así que cada espera es un piso, no un instante exacto. Con 5 fallos terminales consecutivos el endpoint se deshabilita solo (enabled: false, con disabled_at poblado); una entrega exitosa reinicia el contador, y rehabilitarlo es un PATCH con {"enabled": true} o un clic en el dashboard.
Las entregas de un endpoint se listan con su resultado:
curl "https://connect.emisso.ai/api/v1/webhooks/whk_.../deliveries?limit=5" \
-H "Authorization: Bearer connect_sk_..."Respuesta (200):
{
"data": [
{
"id": "whd_...",
"event_type": "sync.succeeded",
"status": "delivered",
"attempts": 1,
"response_status": 200,
"error": null,
"created_at": "2026-08-07T09:00:14.000Z",
"updated_at": "2026-08-07T09:00:15.000Z"
},
{
"id": "whd_...",
"event_type": "sync.failed",
"status": "failed",
"attempts": 6,
"response_status": 500,
"error": "HTTP 500",
"created_at": "2026-08-06T21:10:02.000Z",
"updated_at": "2026-08-07T05:41:10.000Z"
}
],
"pagination": { "cursor": null, "hasMore": false }
}status recorre pending, delivering, delivered y failed; limit acepta de 1 a 100, con 50 por defecto. Una entrega que quedó en failed se reenvía a mano:
curl -X POST "https://connect.emisso.ai/api/v1/webhooks/whk_.../deliveries/whd_.../resend" \
-H "Authorization: Bearer connect_sk_..."Respuesta (200):
{ "data": { "resent": true } }El reenvío vuelve a poner la entrega en pending con el contador de intentos en cero, y el bus la toma en su próxima pasada. El webhook-id no cambia: tu deduplicación lo verá como el mismo evento, que es exactamente lo que es.
Rotar el secreto
curl -X POST https://connect.emisso.ai/api/v1/webhooks/whk_.../roll-secret \
-H "Authorization: Bearer connect_sk_..."Respuesta (200):
{
"data": { "secret": "whsec_..." },
"meta": { "request_id": "req_..." }
}Durante las 24 horas siguientes conviven los dos secretos: cada entrega lleva en webhook-signature una firma por cada secreto vigente, separadas por espacio. El verificador de arriba ya lo contempla (acepta si cualquiera calza), así que la rotación no exige coordinar un despliegue: publica el secreto nuevo en tu endpoint dentro de la ventana y el viejo muere solo al vencer.
Próximos pasos
- La superficie de control completa, con
connect_sessions, syncs y salud: API de control. - El evento
connect_session.consumeden su contexto, con el ciclo de vida del enlace: enlace hosted. - Correlacionar un
request_idcon su ejecución: la bitácora. - Qué responde Connect cuando algo se rechaza y qué hacer en cada caso: errores.
Conecta a tus clientes
El caso plataforma, un producto que conecta a muchos clientes finales, con el modelo de datos, el alta embebida y los topes que importan a escala.
Manejo de errores
Cada error de Connect llega con un código estable, una acción sugerida y un correlativo de soporte. Esta guía enseña a manejarlos; el catálogo completo vive en la referencia.