# 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.

Para configurar webhooks por API, emite una clave con `webhooks:write` o autoriza ese permiso mediante
OAuth. Las consultas de endpoints y entregas requieren `webhooks:read`. Si necesitas ambas operaciones,
otorga ambos permisos: escritura no incluye lectura. Una credencial sin el permiso correspondiente
recibe `403 scope_not_granted`, aunque pueda consultar otros sistemas de tu organización. Las claves
anteriores con permisos explícitos pueden ampliarse editando la clave activa como owner/admin o
emitiendo una nueva; OAuth requiere un nuevo
consentimiento. En el dashboard, administrar webhooks sigue disponible para owner y admin.

## 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.                   |
| `connect_session.consumed`          | Un [enlace de conexión](/docs/operar/enlace-hosted) se usó con éxito y dejó una conexión creada o reautenticada.         |
| `sii.honorarios.issued`             | Se confirmó la emisión de la boleta de honorarios.                                                                       |
| `sii.honorarios.inconclusive`       | El resultado de la emisión requiere reconciliación.                                                                      |
| `sii.honorarios.rejected`           | La solicitud de emisión fue rechazada.                                                                                   |
| `sii.honorarios.document_available` | El documento de la boleta está disponible en Connect.                                                                    |

Los eventos de honorarios incluyen `request_id`, `connection_id`, `document_version` y `state`. El payload no incluye RUT, montos ni el PDF. Consulta el recurso autorizado de la solicitud para obtener su estado y documento.

`sync.partial` se conserva por compatibilidad en REST y suscripciones anteriores, pero no se ofrece como evento disponible ni se emite actualmente: un parcial llega como `sync.succeeded` con el detalle en `outcomes`.

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 evento `connect_session.consumed` 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`.

La misma 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).

## Probar y diagnosticar en el dashboard [#probar-y-diagnosticar-en-el-dashboard]

Abre un endpoint en [Webhooks](https://connect.emisso.ai/webhooks) y selecciona **Probar**. La prueba requiere un endpoint habilitado y un plan con webhooks; puedes solicitar una por endpoint cada 60 segundos. Entra a la misma cola y usa la misma firma y política de reintentos que una entrega de producción.

```json
{
  "type": "webhook.test",
  "timestamp": "2026-09-08T12:00:00.000Z",
  "data": {
    "test": true,
    "message": "Evento de prueba de Emisso Connect",
    "endpoint_id": "whk_..."
  }
}
```

`webhook.test` solo se envía al endpoint elegido. No es suscribible, no se distribuye a los demás endpoints y no representa una sincronización real. Tu receptor debe reconocerlo antes de ejecutar lógica de negocio.

Pausar un endpoint deja de generar entregas para eventos nuevos. Las entregas ya en cola continúan sus intentos. Los eventos ocurridos durante la pausa no se recuperan automáticamente.

### Recuperar un evento anterior [#recuperar-un-evento-anterior]

En las entregas de un endpoint, un propietario o administrador puede abrir **Recuperar un evento anterior** e indicar su ID `whev_…`. Esta acción permite entregar un evento existente a un endpoint creado después del evento. Requiere un plan con webhooks, un endpoint habilitado y filtros compatibles con el tipo de evento y su conexión.

La recuperación conserva el ID, el contenido y la fecha del evento original. Si ya existe una entrega para ese evento y endpoint, abre su historial sin duplicarla ni reiniciar sus intentos; una entrega fallida puede reintentarse desde el inspector. La solicitud de recuperación queda atribuida al usuario en la auditoría. Un resultado encolado no confirma recepción: revisa el HTTP y los intentos de la entrega.

En honorarios, recuperar `sii.honorarios.issued` o `sii.honorarios.document_available` no vuelve a emitir la boleta ni accede al SII. El segundo evento confirma la disponibilidad del documento; solo una respuesta 2xx de su receptor confirma su entrega.

En **Entregas**, selecciona una fila para ver los datos del evento y el historial de intentos. Cada intento observado presenta su hora, HTTP, duración y un motivo seguro cuando falla. Si un error ocurre antes de construir una petición (por ejemplo, al abrir el secreto o validar DNS) no se inventa un timestamp ni una duración HTTP. Las entregas anteriores a esta instrumentación pueden carecer de historial detallado.

Los datos del evento son el payload guardado por Connect. El timestamp registrado corresponde a la petición de ese intento; esta vista **no es una captura HTTP histórica**. No guardamos el cuerpo de respuesta del receptor ni mostramos firmas o secretos. Un `sync.failed` recibido con HTTP `200` significa que tu endpoint recibió correctamente el aviso de una sincronización fallida.

| Resultado de recepción | Qué revisar                                                                             |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `2xx`                  | La entrega terminó. Deduplica por `webhook-id` antes de procesar el evento.             |
| `3xx`                  | Configura la URL final: Connect no sigue redirects.                                     |
| `400`, `404`, `422`    | Revisa ruta, parser y manejo del tipo de evento. Estos rechazos son terminales.         |
| `401`, `403`           | Revisa el secreto, la firma sobre los bytes crudos y las reglas de acceso del receptor. |
| `408`, `429`           | Revisa el tiempo de respuesta o la capacidad; Connect reintentará.                      |
| `5xx`                  | Revisa los errores del receptor; Connect reintentará.                                   |
| Timeout o error de red | Revisa disponibilidad, DNS, certificado TLS y acceso público por HTTPS.                 |

Para probar un receptor que corre en tu computador, publícalo temporalmente mediante un túnel HTTPS y registra esa URL pública. Verifica la firma sobre el cuerpo crudo con el ejemplo siguiente, responde rápido con `2xx` y procesa el trabajo de forma asíncrona. Una URL de `localhost` o una IP privada no es un destino válido para Connect. Usa datos sintéticos para comprobar la integración.

## 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 admite entregas terminales (`failed` o `delivered`), vuelve a ponerlas en `pending` con el presupuesto de intentos en cero y conserva el historial previo. El bus las 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).
