# Enlace hosted de conexión

> Un enlace de un solo uso para que un tercero entregue la clave del SII o del banco: sin cuenta, sin acceso al dashboard y sin que la credencial pase por tu aplicación.



Quien tiene las credenciales de una fuente casi nunca es quien administra Emisso Connect: la clave del SII la tiene el contador, la del banco la tiene el dueño. El **enlace hosted** resuelve eso con una **sesión de conexión**: un enlace de un solo uso que permite entregar credenciales de **una fuente concreta**, para **una organización concreta**, sin crear una cuenta y sin dar acceso a nada más del dashboard.

El ciclo son cinco pasos: creas la sesión, envías el enlace, la persona entrega sus credenciales, tu aplicación se entera por `postMessage` (si lo incrustaste) o por `redirect_uri`, y además recibes un [webhook](/docs/operar/webhooks). Todo queda en la bitácora de la conexión.

## La vía sin código [#la-vía-sin-código]

Cuando no estás incrustando el widget en un producto, no necesitas la API: un agente conectado por [MCP](/docs/agentes/mcp) acuña el enlace en medio de la conversación con `conexiones.enlace.crear`, y por el SDK es una llamada tipada:

```ts
const enlace = await connect.tools.conexiones.enlace.crear({ sistema: "sii" });
```

Por MCP, la misma tool viaja dentro de la meta-tool `execute_write`, porque `conexiones.enlace.crear` escribe:

```json
{ "tool": "conexiones.enlace.crear", "params": { "sistema": "sii" } }
```

Salida esperada, por cualquiera de los dos caminos:

```json
{
  "url": "https://connect.emisso.ai/c/mYw2kQ81xR4tPnZs",
  "dominio": "connect.emisso.ai",
  "sistema": "sii",
  "sistemaNombre": "Servicio de Impuestos Internos",
  "modo": "crear",
  "expiraEn": "2026-08-07T15:32:11.000Z",
  "intentosMaximos": 5,
  "advertencia": "Este enlace pide credenciales de acceso al sistema. Muéstralo siempre con su dominio completo y di quién lo pidió y para qué. No lo presentes como un aviso del banco ni del SII."
}
```

Este carril está deliberadamente recortado: el enlace vence en 1 hora (la persona está presente ahora), no acepta `allowed_origins` ni `redirect_uri` (los dos campos que lo volverían un vector de clickjacking y un open redirect en manos de un modelo de lenguaje) y tiene su propio tope de emisión por actor. La `advertencia` viaja ya escrita para que el agente la muestre tal cual.

Todo lo que sigue en esta página es la vía de integrador: `POST /v1/connect_sessions` con API key, que sí admite incrustar el widget y controlar el retorno.

## 1. Crear la sesión [#1-crear-la-sesión]

```bash
curl -X POST https://connect.emisso.ai/api/v1/connect_sessions \
  -H "Authorization: Bearer connect_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "create",
    "connector_code": "sii",
    "allowed_origins": ["https://app.tu-producto.cl"],
    "redirect_uri": "https://app.tu-producto.cl/conexiones/listo"
  }'
```

Respuesta (`201`):

```json
{
  "data": {
    "id": "cs_7f3ab9c1d2e4f5061728",
    "mode": "create",
    "connector_code": "sii",
    "connection_id": null,
    "token_prefix": "connect_cs_AbC…",
    "allowed_origins": ["https://app.tu-producto.cl"],
    "redirect_uri": "https://app.tu-producto.cl/conexiones/listo",
    "status": "pending",
    "attempts": 0,
    "max_attempts": 5,
    "expires_at": "2026-07-29T12:00:00.000Z",
    "consumed_at": null,
    "resulting_connection_id": null,
    "created_at": "2026-07-28T12:00:00.000Z",
    "url": "https://connect.emisso.ai/c/connect_cs_..."
  },
  "meta": { "request_id": "req_..." }
}
```

<Callout type="warn" title="El token aparece una sola vez">
  La `url` con el token en claro se devuelve **únicamente** en esta respuesta. La base guarda solo su `sha256` y un prefijo visible para nombrarlo en una lista, igual que una API key (`connect_sk_`). Si lo pierdes, emite otra sesión y revoca la anterior: no hay forma de recuperarlo.
</Callout>

| Parámetro         | Qué hace                                                                                                                                                                                                                                   |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `mode`            | `create` (por defecto) emite una conexión nueva; `reauth` reemplaza la credencial de una conexión existente y exige `connection_id`.                                                                                                       |
| `connector_code`  | El código de una fuente conectable, por ejemplo `sii`, `bch_empresas` o `itau_empresas`. El enlace queda fijado a esa fuente: quien lo abre no elige otra. Los códigos válidos son los `codigo` que devuelve `conexiones.sistemas.listar`. |
| `connection_id`   | Obligatorio en `reauth`. Una conexión de otra organización es imposible de referenciar: lo impide una llave foránea compuesta en el esquema, no una validación de código.                                                                  |
| `allowed_origins` | Hasta 5 orígenes `https` exactos, sin comodines ni path. Es la allowlist del iframe y el destino del `postMessage`.                                                                                                                        |
| `redirect_uri`    | A dónde volver al terminar. Debe ser `https`, sin fragmento y, si registraste orígenes, pertenecer a ese conjunto.                                                                                                                         |

Un campo desconocido en el cuerpo devuelve `400 validation_error`: el esquema es estricto a propósito, porque un campo mal escrito es alguien creyendo que está configurando algo. La emisión está limitada por tasa (60 enlaces por hora y por organización); al excederla la respuesta es `429` con `Retry-After`. Los topes completos están en [límites](/docs/operar/limites).

Emitir un enlace abre una conexión facturable, así que sin un plan activo la respuesta es `402 subscription_required` y con un pago vencido, `402 billing_past_due`. Si la organización queda sin plan después de emitirlo, quien abre el enlace ve que no está disponible y no se le piden credenciales.

**Vencimiento:** 24 horas en `mode: "create"` (margen para que el contador lo abra al día siguiente) y 1 hora en `mode: "reauth"` (se emite frente a una credencial ya rota, con la persona presente). **Intentos:** 5 por sesión; un formulario mal tipeado no gasta ninguno, solo cuenta un intento real contra la fuente. **Consumo:** solo al éxito, así que una clave equivocada no quema el enlace: se reintenta sin pedir otro.

`GET /v1/connect_sessions` lista las sesiones con su `token_prefix`, su estado y sus intentos; nunca el token. Acepta `?connection_id=conn_…` para acotar la lista a una [conexión](/docs/conceptos/conexiones).

También puedes emitir el enlace desde el dashboard, sin escribir código: en **Conexiones** para el modo `create`, y en los ajustes de una conexión para el modo `reauth`. Ahí mismo se **revoca**: la sesión pasa a `status: "revoked"`, igual que se revoca una API key, porque no hay borrado en ningún punto de este flujo. El enlace emitido desde el dashboard nace sin `allowed_origins`, así que no es incrustable: para el iframe usa la API.

## 2. Abrir el enlace [#2-abrir-el-enlace]

La persona abre `https://connect.emisso.ai/c/<token>`, ve el nombre de tu organización y la fuente, entrega sus credenciales y recibe la confirmación. No hay registro, ni contraseña, ni acceso a ninguna otra pantalla.

La versión web de esta página trae una **demo interactiva** de esa pantalla: el mismo componente que corre detrás de un enlace de verdad, con una acción falsa que no crea ninguna conexión, no llama al SII y nunca lee la clave que escribas. Puedes recorrerla entera con datos inventados. Si estás leyendo el markdown, el recorrido equivalente está descrito paso a paso en [Qué verá la persona](#qué-verá-la-persona).

<WidgetDemo />

Si el token no existe, venció, ya fue consumido o agotó sus intentos, la página responde el **mismo 404**, sin cuerpo diferenciado. Así, quien pruebe tokens no puede distinguir cuáles existieron o fueron usados.

### Qué verá la persona [#qué-verá-la-persona]

El recorrido completo, para que puedas anticiparlo cuando entregues el enlace, y para que un agente que lo acuña con `conexiones.enlace.crear` sepa qué explicar:

1. **La cabecera dice para quién es.** «Conexión para *\<tu organización>*», y si el enlace lo pidió otra empresa, además «Solicitado por». Nunca se presenta como un aviso del banco ni del SII.
2. **Si la fuente tiene más de un acceso, lo elige primero.** El SII ofrece «Clave tributaria de la empresa» y «Clave tributaria de una persona». Marcar un radio no avanza: hay que confirmar con «Continuar». La fuente ya viene fijada por el enlace; eso no se elige.
3. **Escribe sus credenciales.** Los campos salen del acceso elegido (para el SII, RUT de la empresa y clave) y el RUT se formatea solo. La clave nunca entra a estado de React ni a la analítica.
4. **Revisa qué datos se van a conectar.** «Datos que conectarás» abre una hoja con los módulos. Puede desmarcarlos todos: es válido, y la pantalla le explica que no se cargará nada hasta elegirlos en Ajustes.
5. **Espera mientras Connect entra al sistema.** El indicador de progreso refleja hitos persistidos del intento (credencial guardada, login confirmado, módulos consultados, registros guardados). Un banco puede tardar cerca de un minuto. Puede cerrar la pestaña: el trabajo sigue en el servidor.
6. **Ve el cierre.** Si el acceso quedó verificado, la confirmación y el aviso de que puede cerrar. Si la fuente rechazó la clave, el error aparece **junto al campo** y el enlace **sigue vivo**: reintenta ahí mismo, hasta agotar los 5 intentos, sin pedir otro enlace.

Lo que **no** ve en ningún momento: el dashboard, otras conexiones de tu organización, otros sistemas, ni una pantalla de registro.

## 3. Incrustarlo en tu producto [#3-incrustarlo-en-tu-producto]

Con `allowed_origins` poblado, la página se puede incrustar. La cabecera `frame-ancestors` se construye por sesión desde esa lista, con `'none'` como valor por defecto cuando está vacía.

```html
<iframe
  src="https://connect.emisso.ai/c/connect_cs_..."
  title="Conectar el SII"
  style="width:100%;height:640px;border:0"
></iframe>
```

Reglas: orígenes `https` exactos (`https://app.tu-producto.cl`, sin path ni comodín), máximo 5, y si no registras ninguno el iframe no carga.

## 4. Escuchar el resultado en el navegador [#4-escuchar-el-resultado-en-el-navegador]

Cuando el flujo llega a su estado final, la página emite un `postMessage` **hacia cada origen exacto** que registraste, nunca `"*"`. El mensaje no lleva la credencial ni el token:

```ts
window.addEventListener("message", (event) => {
  if (event.origin !== "https://connect.emisso.ai") return;   // verifica SIEMPRE el origen
  const msg = event.data as { type: string; status: string; connection_id: string | null };
  if (msg.type !== "emisso:connect") return;
  if (msg.status === "connected") {
    console.log("entrega aceptada:", msg.connection_id);
  } else {
    console.warn("la entrega no fue aceptada; el enlace sigue vivo hasta que venza");
  }
});
```

El mensaje es exactamente `{ type: "emisso:connect", status: "connected" | "failed", connection_id: string | null }` y nada más, y llega **una sola vez**: hay un único emisor.

Los dos estados son terminales, pero significan cosas distintas de lo que sugiere el nombre:

* `connected`: Connect aceptó la entrega, vinculó la credencial y consumió el enlace. `connection_id` identifica la conexión creada o actualizada. No implica necesariamente que el login o la primera sincronización ya hayan terminado: cuando la comprobación no puede hacerse en línea, Connect deja un trabajo durable en cola antes de emitir este estado.
* `failed`: el flujo terminó sin una entrega que el anfitrión pueda tratar como completada. `connection_id` es `null` siempre, aunque el intento haya alcanzado a crear una conexión o guardar una credencial. El mismo enlace retoma esa conexión sin duplicarla; si no quedó una comprobación durable en cola, la persona tendrá que ingresar la clave otra vez porque los campos secretos no sobreviven al cierre de la pestaña.

Una clave rechazada por la fuente **no** es un estado terminal y no emite nada: el enlace sigue vivo y la persona reintenta en la misma pantalla, hasta agotar los 5 intentos.

El reintento **rota la credencial sobre la conexión que el enlace ya creó**, nunca crea una segunda. Eso vale también si la persona recarga la página o vuelve a abrir el enlace más tarde: el vínculo vive en la sesión, no en la pestaña. Por eso una sesión todavía `pending` puede traer `resulting_connection_id` con un `conn_…` ya poblado: significa «este enlace ya creó esta conexión y está rotando sobre ella», no que se haya consumido. Lo que marca el consumo sigue siendo `status: "consumed"` con su `consumed_at`.

Si registraste un `redirect_uri`, al llegar a ese estado final la página navega una sola vez (con `history.replace`, para que el botón «atrás» no lleve a un enlace ya consumido) agregando `status=connected|failed` a tu URL, y `connection_id` solo cuando la entrega fue aceptada. El token nunca viaja en esa redirección; si tu `redirect_uri` traía un `token` propio, se elimina antes de navegar.

Si no registraste orígenes y no hay `redirect_uri`, no se emite ningún mensaje ni se navega: el resultado se consulta con `GET /v1/connections` o llega por webhook.

## 5. Recibir el webhook [#5-recibir-el-webhook]

Cuando el enlace se usa con éxito, Connect emite el evento **`connect_session.consumed`** por el mismo bus firmado que los eventos `sync.*` (`POST /v1/webhooks` para registrar un endpoint; la entrega trae `webhook-id`, `webhook-timestamp` y `webhook-signature`, y [la firma se verifica igual que siempre](/docs/operar/webhooks)).

```json
{
  "type": "connect_session.consumed",
  "timestamp": "2026-07-28T12:00:00.000Z",
  "data": {
    "session": { "id": "cs_7f3ab9c1d2e4f5061728" },
    "connection": { "id": "conn_...", "connector": "sii" },
    "verified": true,
    "consumed_at": "2026-07-28T12:00:00.000Z"
  }
}
```

`verified` distingue tres casos: `true` (la verificación contra la fuente corrió y pasó), `false` (corrió y falló; la conexión existe igual, con la credencial que se entregó) y `null` (no hubo un veredicto en línea). `null` puede significar que el conector no declara verificación inmediata o que la comprobación quedó diferida en un trabajo durable; no significa que la fuente ya haya aceptado la credencial ni que los datos estén disponibles. Para disponibilidad de datos, espera `sync.completed` o consulta el estado de la conexión.

<Callout type="info" title="La conexión no espera al webhook">
  Si tu endpoint está caído, la conexión **igual quedó creada**: la entrega se reintenta con backoff y el resultado real está siempre en `GET /v1/connections`. Nunca hagas depender el alta de haber recibido el evento.
</Callout>

## 6. Auditoría [#6-auditoría]

Todo lo que hizo el enlace queda en la pestaña **Bitácora** de la conexión, atribuido con honestidad: las filas de configuración muestran `enlace de conexión (cs_…)` como actor (ni la persona que emitió el enlace, que no entregó nada, ni «sistema», que sería falso), y la verificación contra la fuente aparece en las ejecuciones porque pasa por el mismo `gateway.execute()` que cualquier otra llamada. Qué guarda cada fila y cómo leerla está en [la bitácora](/docs/conceptos/bitacora).

## Modelo de amenaza, en corto [#modelo-de-amenaza-en-corto]

| Riesgo                          | Qué lo acota                                                                                                                                                               |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Fuerza bruta del token          | 256 bits de entropía, búsqueda por igualdad exacta sobre índice único, tope de intentos, expiración corta, el mismo 404 en los cuatro fallos y un limitador de tasa por IP |
| Reuso del enlace                | Un solo uso ganado por la base de datos, no por un chequeo previo en código                                                                                                |
| Clickjacking                    | `frame-ancestors` por sesión, orígenes exactos, sin comodines, `'none'` por defecto                                                                                        |
| Exfiltración por `postMessage`  | `targetOrigin` siempre exacto; el mensaje nunca lleva la credencial ni el token                                                                                            |
| Redirector abierto              | `redirect_uri` obligatoriamente `https`, sin fragmento, y dentro de `allowed_origins` cuando los registraste                                                               |
| Reenvío del enlace a un tercero | Expiración, un solo uso, tope de intentos y **notificación**: la fila en la bitácora más este webhook                                                                      |

El cuadro completo de qué pasa con la credencial una vez entregada (cifrado, aislamiento, revocación) está en [seguridad](/docs/operar/seguridad).
