# 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`:

```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 del conector, cualquiera de los private/tenant: `sii`, `bci_pyme`, `bice_empresas`, `banco_security`, `bch_empresas`. El enlace queda fijado a esa fuente: quien lo abre no elige otra. La lista viva la da `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).

**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). &#x2A;*Intentos:** 5 por sesión; un formulario mal tipeado no gasta ninguno, solo cuenta un intento real contra la fuente. &#x2A;*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.

Ante **cualquier** problema (token inexistente, vencido, ya consumido o con los intentos agotados) la página responde el **mismo 404**, sin cuerpo diferenciado. No es una molestia de diseño: cuatro respuestas distinguibles convertirían el enlace en un oráculo para quien barre tokens.

## 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("conexión lista:", msg.connection_id);
  } else {
    console.warn("terminó sin credencial; 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`: la credencial quedó vinculada y (si el conector declara una tool de verificación) verificada. `connection_id` trae el `conn_…`.
* `failed`: el flujo terminó **sin** credencial utilizable, típicamente porque la persona lo abandonó. `connection_id` es `null` siempre, incluso si un intento anterior alcanzó a crear la conexión: el anfitrión lee `status`, nunca la presencia del id.

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 hay conexión utilizable. 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 &#x2A;*`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` (el conector no declara una tool de verificación, así que no había nada que correr).

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