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:
{ "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á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.
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.
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
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("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_idtrae elconn_….failed: el flujo terminó sin credencial utilizable, típicamente porque la persona lo abandonó.connection_idesnullsiempre, incluso si un intento anterior alcanzó a crear la conexión: el anfitrión leestatus, 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
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 (el conector no declara una tool de verificación, así que no había nada que correr).
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
| 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.