# 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}`](/docs/api-control), 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 [#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](/docs/operar/enlace-hosted) se usó con éxito y dejó una conexión creada o reautenticada.                     |

Cuerpo de un `sync.succeeded`:

```json
{
  "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](/docs/operar/errores):

```json
"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`:

```json
{
  "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](/docs/conceptos/bitacora), así que correlacionar el evento con su ejecución es una búsqueda exacta.

El cuarto evento avisa que un enlace hosted se completó:

```json
{
  "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 [#crear-un-endpoint]

```bash
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`):

```json
{
  "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_..." }
}
```

<Callout type="warn" title="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.
</Callout>

Reglas del alta:

* `event_types` vacío u omitido suscribe a **todos** los eventos, presentes y futuros. Listar tipos explícitos congela la suscripción a esos.
* `connection_id` es opcional y acota el endpoint a los eventos de esa conexión. Debe ser una conexión de tu organización.
* La `url` debe ser `https` y resolver a una dirección pública. Direcciones internas o no resolubles se rechazan con `422 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](https://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 [#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:

```ts
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);
  });
}
```

<Callout type="warn" title="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.
</Callout>

## Reintentos y reenvío [#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`, `408` y `429`.
* **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:

```bash
curl "https://connect.emisso.ai/api/v1/webhooks/whk_.../deliveries?limit=5" \
  -H "Authorization: Bearer connect_sk_..."
```

Respuesta (`200`):

```json
{
  "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:

```bash
curl -X POST "https://connect.emisso.ai/api/v1/webhooks/whk_.../deliveries/whd_.../resend" \
  -H "Authorization: Bearer connect_sk_..."
```

Respuesta (`200`):

```json
{ "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 [#rotar-el-secreto]

```bash
curl -X POST https://connect.emisso.ai/api/v1/webhooks/whk_.../roll-secret \
  -H "Authorization: Bearer connect_sk_..."
```

Respuesta (`200`):

```json
{
  "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 [#próximos-pasos]

* La superficie de control completa, con `connect_sessions`, syncs y salud: [API de control](/docs/api-control).
* El evento `connect_session.consumed` en su contexto, con el ciclo de vida del enlace: [enlace hosted](/docs/operar/enlace-hosted).
* Correlacionar un `request_id` con su ejecución: [la bitácora](/docs/conceptos/bitacora).
* Qué responde Connect cuando algo se rechaza y qué hacer en cada caso: [errores](/docs/operar/errores).
