Connect
Operar

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. Todo queda en la bitácora de la conexión.

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 acuña el enlace en medio de la conversación con conexiones.enlace.crear, y por el SDK es una llamada tipada:

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:

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

Salida esperada, por cualquiera de los dos caminos:

{
  "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

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

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

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.

ParámetroQué hace
modecreate (por defecto) emite una conexión nueva; reauth reemplaza la credencial de una conexión existente y exige connection_id.
connector_codeEl 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_idObligatorio 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_originsHasta 5 orígenes https exactos, sin comodines ni path. Es la allowlist del iframe y el destino del postMessage.
redirect_uriA 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.

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.

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

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.

Se monta al pulsarlo: la página no le manda el código del widget a quien no lo pide.

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

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

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.

<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

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:

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

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

{
  "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.

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.

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.

Modelo de amenaza, en corto

RiesgoQué lo acota
Fuerza bruta del token256 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 enlaceUn solo uso ganado por la base de datos, no por un chequeo previo en código
Clickjackingframe-ancestors por sesión, orígenes exactos, sin comodines, 'none' por defecto
Exfiltración por postMessagetargetOrigin siempre exacto; el mensaje nunca lleva la credencial ni el token
Redirector abiertoredirect_uri obligatoriamente https, sin fragmento, y dentro de allowed_origins cuando los registraste
Reenvío del enlace a un terceroExpiració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.

En esta página