# Listar cuentas de un link Source: https://docs.rail.cl/api-reference/accounts/listar-cuentas-de-un-link /api-reference/openapi.json get /v1/accounts Devuelve todas las cuentas sincronizadas del link (checking, savings, credit_card). Saldos vienen en minor units (CLP zero-decimal, USD en centavos). Por default oculta accounts que el banco dejó de devolver (`removed_from_link=true`) — pasá `include_removed=true` para verlas. # Obtener una cuenta Source: https://docs.rail.cl/api-reference/accounts/obtener-una-cuenta /api-reference/openapi.json get /v1/accounts/{accountId} # Detalle de un evento Source: https://docs.rail.cl/api-reference/events/detalle-de-un-evento /api-reference/openapi.json get /v1/events/{id} # Listar eventos del cliente Source: https://docs.rail.cl/api-reference/events/listar-eventos-del-cliente /api-reference/openapi.json get /v1/events Audit log inmutable de eventos (refresh_intent.succeeded, link.created, etc). Filtrable por type y link_id. # Liveness probe Source: https://docs.rail.cl/api-reference/health/liveness-probe /api-reference/openapi.json get /health Endpoint público sin auth. Devuelve {status:"ok"} si el server responde. # Listar instituciones soportadas Source: https://docs.rail.cl/api-reference/institutions/listar-instituciones-soportadas /api-reference/openapi.json get /v1/institutions Devuelve los bancos soportados con sus productos y holder_types. Filtros: product, holder_type. Por default oculta combinaciones todavía no disponibles (opt-in con include_unavailable=true). # Crear link bancario Source: https://docs.rail.cl/api-reference/links/crear-link-bancario /api-reference/openapi.json post /v1/links Crea un link con credenciales del usuario y dispara el primer sync en background. Requiere secret key (rail_sk_*). Para flujo white-label sin pasar credenciales por tu servidor, usar `/v1/widget_tokens` en su lugar. # Disparar backfill histórico (OB empresa) Source: https://docs.rail.cl/api-reference/links/disparar-backfill-histórico-ob-empresa /api-reference/openapi.json post /v1/links/{linkId}/backfill Solicita el backfill histórico de movimientos (>90 días vía cartola). Solo disponible para banca empresa Santander (holder_type=business). Idempotente vía el estado en DB: si ya está cubierto, no re-baja (usar force:true para re-correr/extender). Corre async en el worker; devuelve un refresh_intent para trackear. # Eliminar link Source: https://docs.rail.cl/api-reference/links/eliminar-link /api-reference/openapi.json delete /v1/links/{linkId} Borra el link y CASCADEa accounts, transactions, refresh_intents y tokens asociados. Requiere secret key. Irreversible. # Listar links del cliente Source: https://docs.rail.cl/api-reference/links/listar-links-del-cliente /api-reference/openapi.json get /v1/links Devuelve los links del cliente actual. Filtros: external_user_id, bank_id, status, last_sync_at (rango). Sort por created_at (default) o last_sync_at. Útil para cleanup desde el cliente B2B y para detectar links atrasados (ej. `sort=last_sync_at:asc` devuelve primero los más viejos). # Obtener un link Source: https://docs.rail.cl/api-reference/links/obtener-un-link /api-reference/openapi.json get /v1/links/{linkId} Devuelve el estado actual del link (status, last_sync_at, refresh_status, etc). # Pausar sync automático Source: https://docs.rail.cl/api-reference/links/pausar-sync-automático /api-reference/openapi.json post /v1/links/{linkId}/pause El cron de 4h va a saltar este link hasta que se llame /resume. Los syncs manuales con /refresh_intents siguen funcionando. # Reanudar sync automático Source: https://docs.rail.cl/api-reference/links/reanudar-sync-automático /api-reference/openapi.json post /v1/links/{linkId}/resume Quita el flag de pausa y resetea next_sync_at para que el próximo cron tick lo levante. # Verificación de ingresos del link Source: https://docs.rail.cl/api-reference/links/verificación-de-ingresos-del-link /api-reference/openapi.json get /v1/links/{linkId}/income Foto de ingresos derivada de los movimientos: ingreso principal (sueldo recurrente), regularidad y promedios mensuales. Útil para underwriting / verificación de renta. Read-only, no dispara sync. # Listar movimientos de una cuenta Source: https://docs.rail.cl/api-reference/movements/listar-movimientos-de-una-cuenta /api-reference/openapi.json get /v1/accounts/{accountId}/movements Paginado por offset/per_page. Siempre excluye movimientos internos (`status` duplicated/reversed). Por default además oculta los `pending` (ej. no-facturados de TC): pasá `confirmed_only=false` para incluirlos (vienen con `pending=true`; mostralos con badge "no facturado"). Para sync incremental usá `synced_since` (cursor monótono forward) en vez de `since` (que filtra por posted_at, puede ser retroactivo). Pasá `include_removals=true` (junto con `synced_since`) para recibir, por el MISMO feed, las bajas por dedup como filas `status="duplicated"` con `canonical_id` — así el consumidor las borra/migra sin inferir por ausencia. # Disparar sync on-demand Source: https://docs.rail.cl/api-reference/refresh-intents/disparar-sync-on-demand /api-reference/openapi.json post /v1/refresh_intents Crea un intent y dispara el sync en background. refresh_type=only_last (default, recomendado): movs recientes, cooldown 5min. refresh_type=historical: 60d + TC facturados, cooldown 60min. Si MFA es requerido, el intent queda en mfa_required hasta que el cliente provea el código via callback o widget channel. # Obtener estado del intent Source: https://docs.rail.cl/api-reference/refresh-intents/obtener-estado-del-intent /api-reference/openapi.json get /v1/refresh_intents/{intentId} # Disparar un webhook de prueba (test mode) Source: https://docs.rail.cl/api-reference/sandbox/disparar-un-webhook-de-prueba-test-mode /api-reference/openapi.json post /v1/sandbox/events/fire Emite un evento fake a tus webhook endpoints de modo test para validar tu receiver (firma HMAC + retry) sin correr un sync. Requiere una secret key de test. # Canjear exchange token por link Source: https://docs.rail.cl/api-reference/widget/canjear-exchange-token-por-link /api-reference/openapi.json post /v1/exchange_tokens/{tokenId} Recibe el et_* devuelto por el widget y lo cambia por el link_id final. Single-use, requiere secret key. # Crear widget token Source: https://docs.rail.cl/api-reference/widget/crear-widget-token /api-reference/openapi.json post /v1/widget_tokens Emite un token efímero wt_* (TTL 10min) para abrir el widget de Rail. Después de que el user conecta el banco, el widget devuelve un et_* que se canjea server-side con POST /v1/exchange_tokens/:id por el link_id. # Autenticación Source: https://docs.rail.cl/authentication Modelo de 4 credenciales — sk, pk, wt, et. Rail usa **4 tipos de credenciales**. Cada uno tiene un scope y lifetime distinto. Si mezclás (mandás un `sk` donde se espera `pk`, o viceversa), la API responde `403`. ## 1. Secret key — `rail_sk_{live|test}_…` Server-to-server. Es la única credencial que puede leer cuentas y movimientos. | Atributo | Valor | | -------------- | --------------------------------------------------------------------------------------- | | Uso | Backend ↔ Rail API | | Lifetime | Permanente (hasta revocar) | | Header | `Authorization: Bearer rail_sk_live_…` | | Scope | `links:read`, `links:write`, `accounts:read`, `movements:read`, `refresh_intents:write` | | Almacenamiento | Env del backend (`RAIL_API_KEY`). **Nunca en frontend.** | ## 2. Publishable key — `rail_pk_{live|test}_…` Browser-safe. La usa el widget para autenticarse al pedir su `widget_token`. | Atributo | Valor | | -------------- | ----------------------------------------------------------- | | Uso | Frontend (widget) | | Lifetime | Permanente | | Header | `Authorization: Bearer rail_pk_live_…` | | Scope | Solo `widget_tokens:write` | | Almacenamiento | Env del frontend (`NEXT_PUBLIC_RAIL_KEY` / `VITE_RAIL_KEY`) | Exponerla es seguro: el único endpoint que la acepta es `POST /v1/widget_tokens`, y el `wt_*` resultante es single-use y efímero. Un atacante con tu `pk_` puede emitir tokens infinitos pero no leer data del banco. ## 3. Widget token — `wt_{8hex}_sec_…` Abre el widget de Rail Connect. Single-use. | Atributo | Valor | | -------- | -------------------------------------------- | | Uso | Inicializar el widget en el frontend | | Lifetime | 10 minutos | | Emisión | `POST /v1/widget_tokens` (con `sk_` o `pk_`) | | Consumo | El widget lo marca `consumed_at` al abrirse | Para **refresh con MFA async**, Rail emite automáticamente un token `ri_{intentId}_sec_…` dentro del `refresh_intent.requires_mfa.widget_token`. El widget lo recibe y lo trata igual que un `wt_*`. ## 4. Exchange token — `et_…` One-shot. El widget lo devuelve al frontend al terminar el connect exitoso. Tu backend lo canjea por el `link_id` real. | Atributo | Valor | | -------- | ------------------------------------------------ | | Uso | Bridge widget → backend del cliente | | Lifetime | 5 minutos | | Emisión | Auto (lo emite Rail al cerrar el widget) | | Consumo | `POST /v1/exchange_tokens/{id}` (requiere `sk_`) | **Por qué existe:** las credenciales bancarias nunca tocan tu backend. Tu frontend solo ve un `et_` opaco que tu backend canjea por el `link_id` definitivo. ## Modo test vs live Las keys vienen en dos modos: * **`test`**: para integración sin tocar bancos reales (sandbox). * **`live`**: producción. Cada llamada que dispara sync llama al banco real. Los recursos creados con `sk_test` solo se ven con keys `_test`. No hay cross-talk entre modos. Los responses incluyen `"mode": "live"` o `"mode": "test"`. **Test mode ya está disponible.** Conecta bancos fake con credenciales mágicas (RUT `11.111.111-1` + clave `rail-sandbox`) y recibe cuentas + movimientos deterministas, sin tocar un banco real. Ver [Test mode (Sandbox)](/test-mode) para la guía completa y la tabla de escenarios. ## Manejo de errores de auth | Status | `error.code` | Significa | | ------ | -------------------- | ----------------------------------------------------------- | | `401` | `invalid_credential` | Key inexistente, revocada o malformada | | `403` | `insufficient_scope` | La key existe pero no tiene scope para ese endpoint | | `403` | `wrong_mode` | Estás usando `sk_test` contra recursos `live` (o viceversa) | Ver [guías → Errores](/guides/errors) para el shape completo del error envelope. # Bancos soportados Source: https://docs.rail.cl/banks Instituciones chilenas disponibles, sus productos y cómo consultarlas en runtime. Rail conecta cuentas de las principales instituciones chilenas, **solo lectura** (saldos + movimientos). La lista viva y autoritativa es `GET /v1/institutions` — consúltala para armar tu UI de selección de banco; cuando sumamos una institución nueva aparece ahí sin que cambies tu integración. El `bank_id` es el identificador que pasas al crear un link o emitir un `widget_token`. El `id` de institución (`cl_`) es el que devuelven los recursos de la API. ## Instituciones | Institución | `bank_id` | Productos | | --------------------- | ----------------- | ------------------------------------------------------------------------------ | | Banco Santander Chile | `santander` | Cuenta corriente, cuenta vista, tarjeta de crédito · [empresa](#banca-empresa) | | Banco de Chile | `banco_chile` | Cuenta corriente, tarjeta de crédito | | BancoEstado | `banco_estado` | CuentaRUT, vista, corriente, ahorro | | BCI | `bci` | Cuenta corriente, tarjeta de crédito | | Scotiabank | `scotia` | Cuenta corriente, tarjeta de crédito | | Banco BICE | `bice` | Cuenta corriente, tarjeta de crédito | | Banco Falabella | `banco_falabella` | CMR Visa (tarjeta de crédito) | | Itaú | `itau` | Cuenta corriente | | Líder BCI | `liderbci` | Tarjeta de crédito | | Tarjeta Cencosud | `cencosud` | Tarjeta de crédito | | Mercado Pago | `mercado_pago` | Wallet (login por correo) | Todas devuelven montos en **CLP** (Santander empresa incluye además **USD**). El histórico por defecto es de 60–90 días; con `refresh_type: "historical"` en el `refresh_intent` se amplía la ventana donde el banco lo permite. ## Comportamientos a tener en cuenta **MFA en cada sync (Santander, BCI).** Estos bancos usan clave dinámica: cada `refresh_intent` pasa por `requires_mfa` y tu app debe reabrir el widget para que la persona ingrese el código. Diseña tu UX contando con esto — ver [MFA handling](/guides/mfa-handling). **BancoEstado** tiene login asíncrono: puede tardar 60–90 segundos en detectar una clave incorrecta. Si tu usuario reporta que la conexión "se queda pegada", espera al menos 90s antes de asumir error. **Mercado Pago** se autentica con **correo** (no RUT) y su validación es multi-paso (código por WhatsApp / escaneo de QR). El widget renderiza el formulario correcto según la institución. Instituciones enfocadas solo en **tarjeta de crédito**: Banco Falabella (CMR), Líder BCI, Tarjeta Cencosud. ## Sandbox En [test mode](/test-mode), **todas las instituciones funcionan** con las credenciales mágicas (RUT `11.111.111-1` + clave `rail-sandbox`) y devuelven data fake determinista — sin tocar el banco real. El `bank_id` que elijas es cosmético en sandbox. ## Banca empresa Las cuentas de empresa **no son un `bank_id` aparte**: usas la misma institución y la distingues con `holder_type: "business"` al emitir el `widget_token`. Mismo modelo que Fintoc — ver [Banca empresa](/guides/banca-empresa). | Institución | `bank_id` | Empresa | | --------------------- | ----------- | -------------------------------------------------- | | Banco Santander Chile | `santander` | Office Banking (RUT usuario + RUT empresa + clave) | Consulta `GET /v1/institutions?holder_type=business` para ver en todo momento qué instituciones soportan empresa y qué credenciales pide cada una (`credential_fields`). ## Consultar disponibilidad en runtime `GET /v1/institutions` lista las instituciones soportadas, sus productos por `holder_type` y los campos de credenciales que pide cada una. Filtra con `?holder_type=` y `?product=`. ```bash theme={null} curl https://api.rail.cl/v1/institutions \ -H "Authorization: Bearer rail_pk_live_…" ``` Response: ```json theme={null} { "object": "list", "data": [ { "object": "institution", "id": "cl_santander", "country": "cl", "name": "Banco Santander Chile", "products": [ { "name": "movements", "holder_type": "individual", "credential_fields": [ { "key": "rut", "label": "RUT", "type": "rut" }, { "key": "password", "label": "Clave", "type": "password" } ] } ] } ] } ``` Úsala para armar tu UI de selección de banco (qué instituciones mostrar para `individual` vs `business`, y qué campos pedir en cada una). # Cuentas Source: https://docs.rail.cl/concepts/accounts Productos bancarios bajo un link: cuentas vista, ahorro y tarjetas de crédito. ## Qué es una cuenta Una **cuenta** representa un producto bancario específico bajo un link. Un link típicamente tiene varias cuentas: CuentaRUT + Vista + tarjeta + ahorro, por ejemplo. Su ID es `acc_` + 16 chars hex. ```json theme={null} { "object": "account", "id": "acc_3f8a1c5d9e2b6741", "link_id": "link_8a2f4c6e1b9d5037", "name": "Tarjeta de Crédito Visa", "official_name": "TARJETA DE CREDITO VISA", "type": "credit_card", "currency": "CLP", "number": "4111111111111234", "last_4": "1234", "balance": { "available": 850000, "current": 150000, "limit": 5000000 }, "holder_id": null, "holder_name": "JANE DOE EXAMPLE", "refreshed_at": "2026-06-03T12:00:00Z", "last_sync_at": "2026-06-03T12:00:00Z", "next_sync_at": "2026-06-03T16:00:00Z", "link_status": "active", "status": "active" } ``` ## Tipos | `type` | Significa | | ------------- | ---------------------------------------------------- | | `checking` | Cuenta corriente o cuenta vista (incluye CuentaRUT). | | `savings` | Cuenta de ahorro (plazo, vivienda, etc). | | `credit_card` | Tarjeta de crédito. `balance.limit` está poblado. | | `other` | No clasificable. Raro. | ## Balance Para `checking` y `savings`: * `available`: lo que el user puede gastar ya (puede incluir línea de crédito). * `current`: saldo contable (sin líneas). * `limit`: `null`. Para `credit_card`: * `available`: cupo disponible (limit − current). * `current`: lo que debe. * `limit`: cupo total. Todos los valores son **bigint en minor units**. Para CLP eso = pesos sin decimal. Para USD/EUR eso = centavos. ## Listar ```bash theme={null} # Todas las cuentas de un link curl "https://api.rail.cl/v1/accounts?link_id=link_xxx" \ -H "Authorization: Bearer rail_sk_live_…" # Una cuenta específica curl https://api.rail.cl/v1/accounts/acc_xxx \ -H "Authorization: Bearer rail_sk_live_…" ``` ## status vs link\_status * `status` = estado de **esta cuenta** específica. * `link_status` = estado del link padre (replicado por conveniencia). | `status` | Significa | | --------- | ------------------------------------------------------------------------------------------- | | `active` | Sigue apareciendo en el banco. | | `removed` | La cuenta fue cerrada o ya no aparece en el banco. Por default no la devolvemos (filtrada). | Para incluir las `removed`: agrega `?include_removed=true`. # Eventos Source: https://docs.rail.cl/concepts/events Catálogo completo de event types con shape de cada event.data. ## Estructura común Todos los eventos siguen este shape base: ```json theme={null} { "object": "event", "id": "evt_4f7c9a2b8d1e3506", "type": "link.refreshed", "created_at": "2026-06-03T15:00:45Z", "data": { /* shape varía por type */ } } ``` | Campo | Tipo | Significa | | ------------ | -------- | -------------------------------------------------------- | | `id` | string | `evt_` + 16 hex. Único por evento. Úsalo como dedup key. | | `type` | string | Tipo del evento (ver catálogo abajo) | | `created_at` | ISO 8601 | Cuándo se emitió el evento | | `data` | object | El recurso afectado en su estado actual (post-update) | ## Catálogo de eventos ### `link.created` Dispara después de un exchange exitoso. Acaba de nacer el link. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "link.created", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "link", "id": "link_8a2f4c6e1b9d5037", "bank_id": "banco_estado", "status": "active", "holder_name": "JANE DOE EXAMPLE", "created_at": "2026-06-03T15:00:45Z" } } ``` ### `link.refreshed` El sync (automático cada 4h o on-demand) terminó OK. Trae los `accounts_count` actualizados. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "link.refreshed", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "link", "id": "link_8a2f4c6e1b9d5037", "status": "active", "last_synced_at": "2026-06-03T15:00:45Z", "next_sync_at": "2026-06-03T19:00:45Z", "accounts_count": 3, "new_movements_count": 5 } } ``` ### `link.expired` 3 fallos consecutivos sin validar. El user debe re-conectar (widget nuevo). ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "link.expired", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "link", "id": "link_8a2f4c6e1b9d5037", "status": "expired", "last_error_code": "rejected_credentials", "expired_at": "2026-06-03T15:00:45Z" } } ``` ### `link.deleted` El cliente borró el link via `DELETE /v1/links/{id}`. Las cuentas y movs asociados también se borraron. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "link.deleted", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "link", "id": "link_8a2f4c6e1b9d5037", "deleted_at": "2026-06-03T15:00:45Z" } } ``` ### `account.created` Apareció una cuenta nueva en el banco bajo un link existente. Útil cuando el user abre una cuenta vista nueva, por ejemplo. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "account.created", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "account", "id": "acc_5b1e4f7a3c9d6280", "link_id": "link_8a2f4c6e1b9d5037", "name": "Cuenta Vista", "type": "checking", "currency": "CLP", "last_4": "9876" } } ``` ### `account.removed` Una cuenta dejó de aparecer en el banco (cerrada o transferida). Queda marcada como `status: removed` en Rail pero no se borra — para auditoría puedes seguir viendo sus movs históricos con `?include_removed=true`. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "account.removed", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "account", "id": "acc_5b1e4f7a3c9d6280", "link_id": "link_8a2f4c6e1b9d5037", "status": "removed", "removed_at": "2026-06-03T15:00:45Z" } } ``` ### `refresh_intent.created` Un refresh\_intent fue encolado. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "refresh_intent.created", "created_at": "2026-06-03T15:00:00Z", "data": { "object": "refresh_intent", "id": "ri_a1b2c3d4e5f6a7b8", "link_id": "link_8a2f4c6e1b9d5037", "status": "created", "refresh_type": "full" } } ``` ### `refresh_intent.requires_mfa` El banco pidió MFA. Tu app debe abrir el widget con `data.requires_mfa.widget_token`. Ver [Manejo de MFA](/guides/mfa-handling). ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "refresh_intent.requires_mfa", "created_at": "2026-06-03T15:00:15Z", "data": { "object": "refresh_intent", "id": "ri_a1b2c3d4e5f6a7b8", "link_id": "link_8a2f4c6e1b9d5037", "status": "requires_mfa", "requires_mfa": { "widget_token": "ri_a1b2c3d4e5f6a7b8_sec_…", "expires_at": "2026-06-03T15:10:15Z" } } } ``` ### `refresh_intent.succeeded` Sync on-demand terminó OK. ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "refresh_intent.succeeded", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "refresh_intent", "id": "ri_a1b2c3d4e5f6a7b8", "link_id": "link_8a2f4c6e1b9d5037", "status": "succeeded", "completed_at": "2026-06-03T15:00:45Z", "summary": { "accounts_updated": 3, "new_movements_count": 5 } } } ``` ### `refresh_intent.failed` Sync on-demand falló. Ver `error_code` en [Errores → Bancos](/guides/errors#bancos-transient--reintentables). ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "refresh_intent.failed", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "refresh_intent", "id": "ri_a1b2c3d4e5f6a7b8", "link_id": "link_8a2f4c6e1b9d5037", "status": "failed", "error_code": "bank_unavailable", "completed_at": "2026-06-03T15:00:45Z" } } ``` ## Suscribirse a tipos específicos Cuando creas un webhook endpoint, especificas qué eventos quieres: ```bash theme={null} curl https://api.rail.cl/v1/webhook_endpoints \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-app.com/webhooks/rail", "enabled_events": [ "link.refreshed", "link.expired", "refresh_intent.succeeded", "refresh_intent.failed", "refresh_intent.requires_mfa" ] }' ``` Para recibir **todos los eventos**, usa `["*"]`. No recomendado en producción — quedas expuesto a eventos nuevos que tu handler no espera. ## Probar webhooks Desde el dashboard ([Webhooks → Endpoint → "Send test event"](https://dashboard.rail.cl/developers/webhooks)) puedes disparar un evento dummy de cualquier tipo. Útil para validar tu firma + handler antes de tener data real. # Links Source: https://docs.rail.cl/concepts/links Un link es la conexión persistente entre un usuario tuyo y un banco. ## Qué es un link Un **link** representa la conexión que un usuario tuyo tiene con uno de sus bancos. Es persistente: una vez creado, vive en tu DB asociado a tu user. Cada link tiene un `link_id` con prefijo `link_` + 16 chars hex. ```json theme={null} { "object": "link", "id": "link_8a2f4c6e1b9d5037", "bank_id": "banco_estado", "holder_type": "individual", "holder_id": "12.345.678-9", "holder_name": "JANE DOE EXAMPLE", "status": "active", "created_at": "2026-05-15T01:50:22Z", "last_synced_at": "2026-06-03T12:00:00Z", "next_sync_at": "2026-06-03T16:00:00Z", "realtime": false } ``` ### Tipo de titular `holder_type` distingue banca personal de empresa: | `holder_type` | `holder_id` | Cuándo | | ---------------------- | --------------------- | -------------- | | `individual` (default) | RUT de la persona | Banca personal | | `business` | RUT de la **empresa** | Banca empresa | Lo defines al emitir el `widget_token` (`holder_type: "business"`). Ver [Banca empresa](/guides/banca-empresa) para el flujo completo. ## Cómo se crea **Solo vía widget.** No hay endpoint público que reciba credenciales en plano. El flow: 1. Tu backend pide `wt_*` con `POST /v1/widget_tokens`. 2. El widget se abre en el frontend con ese token. 3. User mete RUT + clave + MFA dentro del widget. 4. Widget cierra y devuelve `et_*` al frontend. 5. Tu backend canjea `et_*` por `link_id` con `POST /v1/exchange_tokens/{id}`. ## Estados | `status` | Significa | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Recién creado, esperando primer sync. | | `mfa_required` | El sync se quedó esperando un código MFA del user. Resuelve via widget. | | `active` | Funcionando. Syncs corren automáticos cada 4h. | | `failed` | Último sync falló transient. Reintentamos en el próximo cron tick (backoff 1h → 6h → 12h → 24h). | | `credentials_invalid` | El user cambió la clave en el banco. El cron NO retoca este link. Inicia reconnect con `POST /v1/widget_tokens { intent: 'reconnect', link_id }` para que el user reingrese la clave. El link conserva accounts, transactions y holder\_id. | | `expired` | 3 fallos consecutivos sin haber validado nunca el login. Webhook `link.expired` disparado. User debe re-conectar (link nuevo). | | `revoked` | Borrado con `DELETE /v1/links/{id}`. Read-only desde acá; no se reintenta. | ## Modo realtime (en vivo) Algunos links (banca empresa, planes premium) se sincronizan en **tiempo real**: en vez del cron cada 4h, un worker dedicado mantiene la sesión del banco viva y hace *poll* continuo. El campo `realtime` te dice si un link está en este modo: | `realtime` | Significa | | ----------------- | ------------------------------------------------------------------------------------------------ | | `false` (default) | Sync **programado** — cron cada 4h (o el intervalo de `sync_interval_minutes`). | | `true` | Sync en **tiempo real** — sesión viva + poll continuo; los datos se actualizan casi al instante. | **En tu UI, no muestres "cargando" para un link `realtime: true`.** Un sync en curso es su estado **normal** (siempre se está actualizando), no una carga inicial. Muestra algo como **"En vivo"** + "actualizado hace X" usando `last_sync_at`. Interpretar un link realtime como "cargando" confunde al usuario final. ## Listar y leer ```bash theme={null} # Todos los links del cliente (default: hasta 50, sort por created_at desc) curl https://api.rail.cl/v1/links \ -H "Authorization: Bearer rail_sk_live_…" # Un link específico curl https://api.rail.cl/v1/links/link_8a2f4c6e1b9d5037 \ -H "Authorization: Bearer rail_sk_live_…" ``` ### Query params | Param | Tipo | Default | Notas | | ------------------ | -------- | ----------------- | ------------------------------------------------------------------------------------------- | | `external_user_id` | string | — | Filtra por el ID que tú asignaste al user en `POST /v1/links`. | | `bank_id` | enum | — | Uno de los `bank_id` soportados. | | `status` | enum | — | `pending`, `mfa_required`, `active`, `expired`, `failed`, `revoked`, `credentials_invalid`. | | `last_sync_at_gte` | ISO 8601 | — | Solo links con `last_sync_at >= ` este timestamp. | | `last_sync_at_lte` | ISO 8601 | — | Solo links con `last_sync_at <= ` este timestamp. | | `sort` | enum | `created_at:desc` | `created_at:asc`, `created_at:desc`, `last_sync_at:asc`, `last_sync_at:desc`. | | `limit` | integer | `50` | Max `100`. | ### Casos típicos **Encontrar links atrasados** — los que llevan más tiempo sin sincronizar primero (incluye los que nunca sincronizaron): ```bash theme={null} curl "https://api.rail.cl/v1/links?sort=last_sync_at:asc&limit=20" \ -H "Authorization: Bearer rail_sk_live_…" ``` `last_sync_at:asc` usa `NULLS FIRST` automáticamente: los links que nunca sincronizaron salen antes que los más viejos. **Detectar cambios desde tu último poll** — recently synced after a timestamp: ```bash theme={null} curl "https://api.rail.cl/v1/links?last_sync_at_gte=2026-06-07T10:00:00Z&sort=last_sync_at:desc" \ -H "Authorization: Bearer rail_sk_live_…" ``` **Audit de links rotos** — los que pasaron a `credentials_invalid` (el user cambió la clave en el banco y hay que iniciar reconnect): ```bash theme={null} curl "https://api.rail.cl/v1/links?status=credentials_invalid" \ -H "Authorization: Bearer rail_sk_live_…" ``` Para cada uno de esos, puedes disparar el flow de reconnect: `POST /v1/widget_tokens { intent: 'reconnect', link_id }`. ### Sort por `last_sync_at` y NULLs * `sort=last_sync_at:asc` → `NULLS FIRST`. Útil para encontrar los más atrasados (nunca sincronizados + los más viejos primero). * `sort=last_sync_at:desc` → `NULLS LAST`. Útil para encontrar los recién actualizados sin que los nunca-sincronizados inflen el head. ## Pausar / despausar ```bash theme={null} curl https://api.rail.cl/v1/links/link_xxx/pause \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" curl https://api.rail.cl/v1/links/link_xxx/resume \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" ``` Útil si el user pausó la cuenta en tu app — no quieres seguir haciendo syncs. ## Borrar ```bash theme={null} curl https://api.rail.cl/v1/links/link_xxx \ -X DELETE \ -H "Authorization: Bearer rail_sk_live_…" ``` Borra el link, todas sus cuentas y movimientos asociados, y revoca sus credenciales del vault. Irreversible. # Movimientos Source: https://docs.rail.cl/concepts/movements Cargos y abonos de una cuenta. ## Qué es un movimiento Un **movimiento** es una transacción individual en una cuenta. Su ID es `mov_` + 16 chars hex. ```json theme={null} { "object": "movement", "id": "mov_6c8d1f3a9e5b2074", "account_id": "acc_3f8a1c5d9e2b6741", "amount": -30000, "currency": "CLP", "type": "card_payment", "description": "MERCADOLIBRE CL", "comment": null, "reference_id": null, "pending": false, "status": "confirmed", "transaction_date": "2026-06-01T04:00:00+00:00", "post_date": "2026-06-01T04:00:00+00:00", "recipient_account": { "holderName": "MERCADOLIBRE CL" }, "sender_account": null } ``` ## Amount con signo embebido El `amount` es **bigint en minor units con signo**: * `negativo` = egreso (cargo, pago, transferencia salida) * `positivo` = abono (depósito, refund, transferencia entrante) Para CLP, 1 peso = 1 minor unit. Para USD/EUR, 1 dólar/euro = 100 minor units (centavos). ```js theme={null} // CLP — $170.329 de cargo { "amount": -30000, "currency": "CLP" } // USD — $50.00 de abono { "amount": 5000, "currency": "USD" } ``` ## Type Valores posibles: | `type` | Significa | | ----------------- | -------------------------------------------- | | `transfer` | Transferencia electrónica recibida o emitida | | `card_payment` | Pago con tarjeta de débito/crédito | | `refund` | Devolución o reverso | | `cash_withdrawal` | Retiro de efectivo (cajero) | | `service_payment` | Pago de servicio (luz, agua, etc) | | `salary` | Sueldo o ingreso recurrente identificado | | `fee` | Comisión o cargo bancario | | `interest` | Intereses | | `other` | Sin categoría clara | ## Status | `status` | Significa | | ------------ | ------------------------------------------------------------------------- | | `confirmed` | Default. Mov contabilizado, definitivo. | | `processing` | En proceso. Equivalente a `pending: true`. | | `reversed` | Reversado por el banco. Su counterparte tiene `status: reversed` también. | | `duplicated` | Detectado como duplicado por nuestro guardián anti-duplicados. | Por default, `GET /v1/accounts/{id}/movements` filtra a `confirmed` + `pending: false`. Para incluir todos: `?confirmed_only=false`. ## Listar ```bash theme={null} curl "https://api.rail.cl/v1/accounts/acc_xxx/movements?per_page=100&page=1" \ -H "Authorization: Bearer rail_sk_live_…" ``` Paginación con header `Link` RFC 5988 + `X-Total-Count`. Ver [Paginación](/guides/pagination). ### Filtros útiles | Query param | Comportamiento | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `since=2026-05-01` | Solo movs con `post_date >= since`. `post_date` puede ser retroactivo (refunds, back-dated). | | `until=2026-06-01` | Solo movs con `post_date <= until`. | | `synced_since=2026-06-01T00:00:00Z` | **Cursor monótono forward** — recomendado para sync incremental. Filtra por `synced_at`, no por `post_date`. | | `confirmed_only=false` | Incluye `processing`, `reversed`, `duplicated`. | | `per_page=200` | Hasta 300 por página. Default 30. | ## Sync incremental recomendado Para mantenerte al día sin re-traer todo, usa `synced_since`: ```bash theme={null} # Primera sync — trae todo curl "https://api.rail.cl/v1/accounts/acc_xxx/movements?per_page=200&page=1" \ -H "Authorization: Bearer rail_sk_live_…" # Sucesivas — solo lo nuevo desde el cursor curl "https://api.rail.cl/v1/accounts/acc_xxx/movements?per_page=200&synced_since=2026-06-03T12:36:36Z" \ -H "Authorization: Bearer rail_sk_live_…" ``` Guarda `last_sync_at` que viene en cada response y úsalo como `synced_since` en la próxima call. Esto te asegura que **no pierdes movs retroactivos** que el banco posteó después de su fecha real. # Refresh Intents Source: https://docs.rail.cl/concepts/refresh-intents Sync on-demand de un link, con soporte para MFA async. ## Por qué existen Rail corre syncs automáticos cada 4h por cada link. Pero a veces el cliente quiere forzar un sync **ya** — por ejemplo cuando el user abre la app y quiere ver lo más reciente. Un **refresh intent** representa ese deseo de sync on-demand. Su ID es `ri_` + 16 chars hex. ```json theme={null} { "object": "refresh_intent", "id": "ri_a1b2c3d4e5f6a7b8", "link_id": "link_8a2f4c6e1b9d5037", "status": "succeeded", "refresh_type": "full", "requires_mfa": null, "created_at": "2026-06-03T15:00:00Z", "started_at": "2026-06-03T15:00:01Z", "completed_at": "2026-06-03T15:00:45Z", "error_code": null } ``` ## Crear ```bash theme={null} curl https://api.rail.cl/v1/refresh_intents \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "link_id": "link_xxx", "refresh_type": "full" }' ``` `refresh_type`: * `full` — todas las cuentas, todos los movs (default). * `historical` — incluye movs viejos (60+ días). * `only_last` — solo cuentas (skip movs). Más rápido. ## Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> created created --> in_progress in_progress --> requires_mfa: MFA requerido in_progress --> succeeded in_progress --> failed requires_mfa --> in_progress: User resuelve MFA en widget requires_mfa --> failed: Timeout o user abandona succeeded --> [*] failed --> [*] ``` | `status` | Significa | | -------------- | ------------------------------ | | `created` | Encolado. Aún no empezó. | | `in_progress` | El worker está syncando. | | `requires_mfa` | El banco pidió MFA. Ver abajo. | | `succeeded` | Sync terminado OK. | | `failed` | Sync falló. Ver `error_code`. | ## MFA async Algunos bancos (Santander, BCI a veces) piden MFA en cada sync. Cuando eso pasa: 1. El intent queda en `status: requires_mfa`. 2. El campo `requires_mfa.widget_token` contiene un `ri_{intentId}_sec_…` listo para abrir el widget. 3. Tu frontend abre el widget con ese token. 4. User resuelve el MFA dentro del widget. 5. El widget cierra, el sync continúa server-side. 6. El intent pasa a `succeeded` o `failed`. ```json theme={null} { "object": "refresh_intent", "id": "ri_xxx", "status": "requires_mfa", "requires_mfa": { "widget_token": "ri_xxx_sec_abc123…", "expires_at": "2026-06-03T15:10:00Z" } } ``` ## Polling vs Webhooks Dos formas de saber cuándo termina: ### Polling ```bash theme={null} curl https://api.rail.cl/v1/refresh_intents/ri_xxx \ -H "Authorization: Bearer rail_sk_live_…" ``` Cada 5-10 segundos hasta que `status` sea `succeeded`, `failed` o `requires_mfa`. ### Webhooks (recomendado) Suscríbete a los eventos `refresh_intent.succeeded`, `refresh_intent.failed`, `refresh_intent.requires_mfa`. Ver [Guías → Webhooks](/guides/webhooks). ## Cooldown y deduplicación * **Cooldown 5min**: si pediste un intent para un link, no puedes pedir otro full hasta 5min después. Para `only_last` no hay cooldown. * **Unique in-progress**: solo puede haber 1 intent activo por link a la vez. Si pides otro, te devolvemos el existente. ```bash theme={null} # Si ya hay uno in_progress, devuelve ese (no crea otro) curl https://api.rail.cl/v1/refresh_intents \ -X POST \ -d '{"link_id": "link_xxx", "refresh_type": "full"}' # → 200 con el intent activo, no 201 ``` ## Error codes comunes | `error_code` | Significa | | --------------------------- | --------------------------------------------------------------------- | | `bank_unavailable` | El banco está caído o muy lento. Reintentar en 10min. | | `rejected_credentials` | Clave o RUT inválido. El user debe re-conectar. | | `requires_mfa_user_timeout` | Se emitió `wt_*` pero el user no resolvió en 10min. | | `bank_format_change` | El banco cambió su HTML y nuestro parser rompió. Rail está sobre eso. | # Banca empresa Source: https://docs.rail.cl/guides/banca-empresa Conecta cuentas de empresa (no solo personas) con el mismo flujo, distinguiendo con holder_type. Rail soporta cuentas **de empresa** además de las de personas. El modelo es el mismo que usa Fintoc: **no hay una institución aparte** para empresa — usas la misma institución (ej. `santander`) y distingues el tipo de titular con un campo `holder_type`. | `holder_type` | Para qué | Quién es el titular | | ---------------------- | -------------- | ------------------------------ | | `individual` (default) | Banca personal | La persona (su RUT) | | `business` | Banca empresa | La empresa (RUT de la empresa) | El `holder_type` lo decides **tú** (el integrador) al emitir el `widget_token` — no lo elige el usuario final. Tú sabes si estás onboardeando una persona o una empresa. Si lo omites, es `individual`. ## Flujo de integración Es el mismo flujo del [widget](/guides/widget), con un solo cambio: al emitir el `widget_token` envías `holder_type: "business"`. El widget se encarga del resto (muestra el form correcto y filtra los bancos que soportan empresa). ```mermaid theme={null} sequenceDiagram participant App as Tu frontend participant Backend as Tu backend participant Widget as widget.rail.cl participant Rail Backend->>Rail: POST /v1/widget_tokens { holder_type: "business" } Rail-->>Backend: { id: "wt_..." } Backend-->>App: wt_* App->>Widget: Rail.openWidget({ token, pk }) Widget->>Rail: GET /v1/widget/info (lee holder_type) Rail-->>Widget: holder_type=business + bancos empresa Widget->>App: form empresa (RUT empresa + RUT persona + clave) Widget->>Rail: sync inicial Rail-->>Widget: link_id, et_* Widget-->>App: onSuccess(et_*) App->>Backend: POST /callback { et_* } Backend->>Rail: POST /v1/exchange_tokens/{et} Rail-->>Backend: { link_id, holder_type: "business" } ``` ### 1. (Opcional) Descubre qué bancos soportan empresa `GET /v1/institutions` lista las instituciones con sus productos por `holder_type`, y los campos de credenciales que pide cada una. Útil si quieres armar tu propia UI de selección antes de abrir el widget. ```bash theme={null} curl "https://api.rail.cl/v1/institutions?holder_type=business" \ -H "Authorization: Bearer rail_pk_live_…" ``` ```json theme={null} { "object": "list", "data": [ { "object": "institution", "id": "cl_santander", "country": "cl", "name": "Banco Santander Chile", "products": [ { "name": "movements", "holder_type": "business", "credential_fields": [ { "key": "rut_empresa", "label": "RUT empresa", "type": "rut" }, { "key": "rut", "label": "RUT persona", "type": "rut" }, { "key": "password", "label": "Clave portal web", "type": "password" } ] } ] } ] } ``` `credential_fields` te dice exactamente qué campos pide el banco para ese `holder_type`. El widget los renderiza solo; esto es para cuando quieres mostrar el listado o el form tú mismo. ### 2. Emite el widget token con `holder_type: "business"` ```bash theme={null} curl https://api.rail.cl/v1/widget_tokens \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "holder_type": "business", "institution_id": "cl_santander" }' ``` `institution_id` es opcional. Si lo envías, el widget abre directo en ese banco. Si no, el widget muestra el picker **filtrado a los bancos que soportan empresa** (no aparecen los de solo-personas ni los marcados "Pronto"). ### 3. Abre el widget (igual que siempre) El frontend no cambia — el `holder_type` ya viajó en el token. El widget detecta que es empresa y muestra los **3 campos** (en vez de los 2 de personas): | Campo | Qué es | | -------------------- | ------------------------------------------ | | **RUT empresa** | RUT de la empresa (es el titular del link) | | **RUT persona** | RUT del usuario que ingresa al portal | | **Clave portal web** | La clave de ese usuario | ```html theme={null} ``` ### 4. Canjea el exchange token Igual que en el flujo personal. El `link` resultante queda con `holder_type: "business"` y su `holder_id` es el **RUT de la empresa**. ```json theme={null} { "object": "link", "id": "link_8a2f4c6e1b9d5037", "bank_id": "santander", "holder_type": "business", "holder_id": "76.543.210-3", "status": "active" } ``` De acá en adelante, lees cuentas y movimientos exactamente igual que con un link personal (`GET /v1/accounts?link_id=…`, etc). ## Disponibilidad **Santander Empresa (Office Banking) está en habilitación temprana.** Puedes integrar el flujo y tus usuarios pueden ingresar credenciales hoy, pero la sincronización de cuentas/movimientos para empresa se está terminando de activar. Consulta `GET /v1/institutions?holder_type=business` para ver en todo momento qué bancos están disponibles para empresa — cuando sumemos otro, aparece ahí sin que cambies tu integración. # Errores Source: https://docs.rail.cl/guides/errors Shape estándar y catálogo de error codes. ## Shape estándar Todos los errores 4xx/5xx siguen este shape: ```json theme={null} { "error": { "type": "invalid_request_error", "code": "missing_field", "message": "El campo 'link_id' es requerido.", "param": "link_id", "doc_url": "https://docs.rail.cl/guides/errors#missing_field" } } ``` | Field | Tipo | Significa | | --------- | ------- | -------------------------------------------- | | `type` | string | Categoría del error | | `code` | string | Identificador específico (usar para lógica) | | `message` | string | Texto en español para mostrar al user | | `param` | string? | Field específico que rompió (opcional) | | `doc_url` | string? | URL a esta doc con anchor al code (opcional) | ## Types | `type` | HTTP | Significa | | ----------------------- | ---- | ------------------------------------------------------------- | | `invalid_request_error` | 400 | Validación de body/query/params | | `authentication_error` | 401 | Credencial inválida o ausente | | `permission_error` | 403 | Credencial válida pero sin scope | | `not_found_error` | 404 | Recurso no existe o no pertenece al cliente | | `conflict_error` | 409 | Estado conflictivo (idempotency\_conflict, in\_progress, etc) | | `rate_limit_error` | 429 | Demasiados requests — backoff | | `bank_error` | 502 | El banco rebotó (transient) | | `internal_error` | 500 | Error nuestro — reintenta | ## Catálogo de codes ### Auth | `code` | Significa | | -------------------- | --------------------------------------------------------- | | `invalid_credential` | Key inexistente, malformada o revocada | | `insufficient_scope` | Key válida pero sin permisos para el endpoint | | `wrong_mode` | Estás usando `sk_test` contra recursos `live` o viceversa | ### Validación | `code` | Significa | | --------------- | -------------------------------------- | | `missing_field` | Falta un campo requerido (ver `param`) | | `invalid_field` | Campo con valor inválido (ver `param`) | | `unknown_field` | Campo no reconocido en el body | ### Recursos | `code` | Significa | | -------------------- | ------------------------------------------------ | | `link_not_found` | El `link_id` no existe o no pertenece al cliente | | `account_not_found` | El `account_id` no existe | | `bank_not_supported` | El `bank_id` no está habilitado | ### Refresh intents | `code` | Significa | | ---------------------------- | -------------------------------------------------- | | `refresh_intent_in_progress` | Ya hay un intent activo para este link | | `refresh_intent_cooldown` | Espera los 5min de cooldown desde el último intent | ### Bancos (transient — reintentables) | `code` | Significa | | ---------------------- | -------------------------------------------------------------------------------------------- | | `bank_unavailable` | Banco caído o muy lento | | `bank_format_change` | Banco cambió HTML, parser nuestro está roto. Rail está sobre eso. | | `rejected_credentials` | Banco rechazó las creds. User debe re-conectar (no es transient técnicamente — es del user). | ### Idempotency | `code` | Significa | | ---------------------- | ------------------------------------------ | | `idempotency_conflict` | Misma `Idempotency-Key` con body diferente | ## Manejo recomendado ```ts theme={null} async function railFetch(path: string, opts: RequestInit): Promise { const res = await fetch(`https://api.rail.cl${path}`, opts); if (!res.ok) { const body = await res.json().catch(() => ({})); const err = body.error ?? {}; throw new RailError(res.status, err.code, err.message, err.param); } return res.json(); } class RailError extends Error { constructor( public status: number, public code: string, message: string, public param?: string, ) { super(message); } } ``` Después, en tu app: ```ts theme={null} try { await railFetch('/v1/refresh_intents', { ... }); } catch (e) { if (e instanceof RailError) { if (e.code === 'refresh_intent_in_progress') { // No es error real — ya está corriendo return; } if (e.code === 'rejected_credentials') { // Notifica al user para que re-conecte } if (e.status >= 500) { // Transient, reintentar con backoff } } } ``` # Estados de banco Source: https://docs.rail.cl/guides/estados-de-banco Mostrá en tus conexiones cuándo un banco está en mantención o pausado, sin confundirlo con un link roto. Un banco puede quedar **temporalmente fuera** (mantención programada) o **pausado** por Rail (por un incidente operativo). Eso es distinto de que el *link* de un usuario esté roto. Esta guía es para mostrar el estado del **banco** en tus **conexiones ya existentes**. El flujo de conexión nueva lo maneja el widget. ## Estado del link ≠ estado del banco Durante una mantención o pausa, **`link.status` sigue `active`** — las credenciales del usuario están bien, el banco es el que no está disponible. **No dispares un reconnect** en estos casos: no es problema del usuario. | Campo | Qué es | ¿Requiere acción del usuario? | | ---------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------- | | `link.status` (`active` / `failed` / `credentials_invalid` / `mfa_required` / `expired`) | Salud del **link** (credenciales, MFA) | **Sí** | | `link.institution.status` | Estado del **banco** | **No** — el link sigue sano | ## Estados del banco En `link.institution.status`: | status | Significa | Campo extra | | ------------- | ----------------------------------------------- | ---------------------------------------------------------- | | `available` | Normal | — | | `paused` | Pausa temporal (mantención de Rail / incidente) | `institution.resumes_at` (ISO, opcional) | | `maintenance` | Ventana de mantención programada del banco | `institution.maintenance` = `{ starts_at, ends_at, note }` | | `disabled` | No disponible (más permanente) | — | ## Dónde consultarlo Usá las **dos** vías: el recurso como fuente de verdad y los webhooks para tiempo real. ### Pull — al renderizar (fuente de verdad) `GET /v1/links` o `GET /v1/links/{id}`. Cada link trae el estado del banco embebido: ```json theme={null} { "id": "link_a1b2c3", "status": "active", "last_sync_at": "2026-07-29T12:00:00Z", "institution": { "id": "cl_banco_chile", "name": "Banco de Chile", "status": "maintenance", "resumes_at": null, "maintenance": { "starts_at": "2026-07-29T10:00:00Z", "ends_at": "2026-07-29T14:30:00Z", "note": "Mantención de canales digitales" } } } ``` ### Push — tiempo real Dos webhooks (opt-in). Ambos traen `affected_link_ids` para saber qué links tuyos tocar. Suscribilos en el [dashboard](https://dashboard.rail.cl/developers/webhooks) → grupo **Instituciones**, o agregándolos al array `events` del webhook endpoint. ```json institution.status_changed theme={null} { "type": "institution.status_changed", "data": { "bank_id": "banco_chile", "status": "paused", "previous_status": "available", "reason": "...", "resumes_at": null, "affected_link_ids": ["link_a1b2c3"] } } ``` ```json institution.maintenance_scheduled theme={null} { "type": "institution.maintenance_scheduled", "data": { "bank_id": "banco_chile", "starts_at": "2026-07-29T10:00:00Z", "ends_at": "2026-07-29T14:30:00Z", "note": "Mantención de canales digitales", "affected_link_ids": ["link_a1b2c3"] } } ``` El push es best-effort: puede no llegar (endpoint caído, un link nuevo durante la pausa no recibe evento retroactivo, `affected_link_ids` es un snapshot). Por eso: al **abrir** la vista mostrá lo que dice `link.institution.status` (pull, siempre exacto); mientras está **abierta**, actualizá en vivo con los webhooks (push). ## UX sugerida en la tarjeta | status | Copy | CTA | | ------------- | -------------------------------------------------------------- | --- | | `paused` | "Banco en mantención" (+ "vuelve \~HH:MM" si hay `resumes_at`) | — | | `maintenance` | "Banco en mantención hasta HH:MM" (`maintenance.ends_at`) | — | | `disabled` | "Banco no disponible por ahora" | — | En los tres: **sin botón de reconexión**. El link está sano; los datos se actualizan solos cuando el banco vuelve. # Idempotency Source: https://docs.rail.cl/guides/idempotency Reintenta POSTs sin riesgo de doble efecto. ## Por qué Si tu request falla con timeout o 5xx, no sabes si Rail lo recibió o no. Si reintentas, ¿corres el riesgo de crear el recurso 2 veces? Con `Idempotency-Key` no. ## Uso Envía el header `Idempotency-Key` con un UUID que generaste tú: ```bash theme={null} curl https://api.rail.cl/v1/refresh_intents \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"link_id": "link_xxx", "refresh_type": "full"}' ``` Rail: * En el primer request: procesa normal y **guarda el response** asociado a tu key. * En requests subsecuentes con la **misma key**: devuelve el response cacheado, **sin re-procesar**. ## TTL 24 horas. Después de eso, la key se libera y un nuevo request con esa key se procesa como nuevo. ## Reglas * Solo aplica a **POST** y **DELETE**. GETs son idempotentes por naturaleza. * La key debe ser única por intención lógica de tu cliente — genérala con `uuid.v4()` o equivalente. * Si reúsas la misma key con un body **diferente**, devuelve `409 Conflict` con `error.code: idempotency_conflict`. ## Endpoints que lo aceptan | Endpoint | Recomendado | | ------------------------------- | -------------------------------------------------------------- | | `POST /v1/widget_tokens` | | | `POST /v1/refresh_intents` | | | `POST /v1/exchange_tokens/{id}` | (one-shot por naturaleza) | | `POST /v1/webhook_endpoints` | | | `DELETE /v1/links/{id}` | | # Manejo de MFA Source: https://docs.rail.cl/guides/mfa-handling Walkthrough end-to-end del flow MFA async, desde el backend hasta el widget. Algunos bancos chilenos (Santander, BCI, BancoEstado a veces) **requieren clave dinámica en cada sync**. Eso significa que cuando creas un `refresh_intent`, el banco pide MFA y el sync no puede completarse hasta que el user lo resuelva. Rail maneja esto de forma asíncrona: el intent queda en `status: requires_mfa` con un widget\_token listo para abrir. Tu app debe reabrir el widget para que el user meta el código. ## Flow completo ```mermaid theme={null} sequenceDiagram participant App as Tu frontend participant Backend as Tu backend participant Rail participant Bank as Banco participant Widget as Rail Widget App->>Backend: "Sincroniza ahora" Backend->>Rail: POST /v1/refresh_intents { link_id } Rail-->>Backend: ri_xxx (status: created) Backend-->>App: ri_xxx Note over Rail,Bank: Worker corre el sync Rail->>Bank: login Bank-->>Rail: pide MFA Rail->>Rail: emite wt para MFA Rail-->>Backend: webhook refresh_intent.requires_mfa Backend-->>App: WebSocket / polling notification App->>Widget: openWidget({ token: ri_xxx_sec_... }) Note over Widget,Bank: User mete código Widget->>Rail: submit MFA Rail->>Bank: continue session Bank-->>Rail: cuentas + movs Rail-->>Backend: webhook refresh_intent.succeeded Backend-->>App: notification App->>Backend: GET /v1/accounts ``` ## Paso a paso ### 1. Backend — crea el refresh intent ```ts theme={null} async function startRefresh(linkId: string) { const r = await fetch('https://api.rail.cl/v1/refresh_intents', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify({ link_id: linkId, refresh_type: 'full' }), }); return r.json(); } ``` ### 2. Backend — handler del webhook ```ts theme={null} // /webhooks/rail export async function POST(req: Request) { const body = await req.text(); // ... verificar firma (ver guía Webhooks) const event = JSON.parse(body); if (event.type === 'refresh_intent.requires_mfa') { const intent = event.data; // Guarda el widget_token asociado al user await db.from('pending_mfa').insert({ user_id: getUserIdFromLink(intent.link_id), intent_id: intent.id, widget_token: intent.requires_mfa.widget_token, expires_at: intent.requires_mfa.expires_at, }); // Notifica al frontend (WebSocket, push, etc) await notifyUser(intent.link_id, 'mfa_required'); } if (event.type === 'refresh_intent.succeeded') { await db.from('pending_mfa').delete().eq('intent_id', event.data.id); await notifyUser(event.data.link_id, 'sync_complete'); } if (event.type === 'refresh_intent.failed') { await db.from('pending_mfa').delete().eq('intent_id', event.data.id); await notifyUser(event.data.link_id, 'sync_failed', event.data.error_code); } return new Response('ok'); } ``` ### 3. Frontend — escuchar notificación + abrir widget ```tsx theme={null} 'use client'; import { useEffect } from 'react'; export function LinkSyncStatus({ linkId }: { linkId: string }) { useEffect(() => { // WebSocket / SSE / polling — el patrón que uses para notificaciones const sub = supabase .channel(`link:${linkId}`) .on('broadcast', { event: 'mfa_required' }, async ({ payload }) => { // Backend envió el widget_token vía notificación window.Rail.openWidget({ token: payload.widget_token, publishableKey: process.env.NEXT_PUBLIC_RAIL_KEY!, onSuccess: () => { // El MFA fue resuelto. El sync continúa server-side. // Muestra un loader hasta que el webhook succeeded llegue. setSyncing(true); }, onExit: (reason) => { if (reason === 'timeout') { alert('El código expiró. Inténtalo de nuevo.'); } }, }); }) .subscribe(); return () => sub.unsubscribe(); }, [linkId]); } ``` ## Alternativa: polling (sin webhooks) Si no tienes webhooks setup todavía, puedes polear el intent: ```ts theme={null} async function pollIntent(intentId: string): Promise { while (true) { const r = await fetch(`https://api.rail.cl/v1/refresh_intents/${intentId}`, { headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}` }, }); const intent = await r.json(); if (['succeeded', 'failed', 'requires_mfa'].includes(intent.status)) { return intent; } await new Promise((r) => setTimeout(r, 5000)); // 5s entre polls } } ``` No polees más rápido que cada 5 segundos. Si excedes 100 req/min vas a tirar 429. Ver [Rate limits](/rate-limits). ## Casos edge ### El widget\_token expiró antes de que el user lo abra `requires_mfa.expires_at` es 10 minutos desde la emisión. Si el user no abrió el widget a tiempo: * El próximo refresh\_intent crea un nuevo widget\_token. * El intent viejo queda en `status: failed` con `error_code: requires_mfa_user_timeout`. Muéstrale al user un botón "Reintentar" que dispare un nuevo `POST /v1/refresh_intents`. ### El user resuelve el MFA pero el banco lo rechaza El sync sigue su curso server-side y termina en `status: failed` con `error_code: rejected_credentials`. Eso significa que la clave dinámica fue inválida. Pídele que reintente. ### El user cierra el widget sin resolver `onExit(reason)` recibe `'user_close'`. El intent queda en `requires_mfa` hasta que expire (10min). Si quieres cancelar antes: ```ts theme={null} await fetch(`https://api.rail.cl/v1/refresh_intents/${intentId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}` }, }); ``` ## UX recomendada * **Muestra un estado claro**: "Esperando código del banco" mientras está en `requires_mfa`. * **Botón cancelar**: deja al user salir del flow sin que quede colgado. * **Texto del banco**: si sabes que el banco pide MFA en cada sync (Santander, BCI), avísalo upfront: "Tu banco va a pedirte un código cada vez que sincronicemos". * **Recordatorio app del banco**: "Abre tu app de Santander para ver el código". * **Timeout visual**: countdown del `expires_at` para que el user sepa cuánto le queda. # Paginación Source: https://docs.rail.cl/guides/pagination Paginación con header Link RFC 5988 + X-Total-Count. ## Modelo Los endpoints de lista en Rail devuelven un **array puro** (no envuelto en `{ data: [...] }`). La info de paginación va en headers: | Header | Significa | | --------------- | ------------------------------------------------------- | | `X-Total-Count` | Total de resultados que matchean tu query. | | `Link` | URLs a páginas siguientes/anteriores/última (RFC 5988). | ```bash theme={null} curl -i "https://api.rail.cl/v1/accounts/acc_xxx/movements?per_page=10" \ -H "Authorization: Bearer rail_sk_live_…" ``` Response headers: ``` HTTP/2 200 content-type: application/json x-total-count: 238 link: ; rel="next", ; rel="last" ``` ## Query params | Param | Default | Max | | ---------- | ------- | ----- | | `page` | `1` | — | | `per_page` | `30` | `300` | ## Parsear el header `Link` ```ts theme={null} function parseLinkHeader(header: string): Record { const links: Record = {}; for (const part of header.split(',')) { const m = part.trim().match(/^<(.+)>;\s*rel="(.+)"$/); if (m) links[m[2]] = m[1]; } return links; } // Uso const res = await fetch(url, { headers }); const links = parseLinkHeader(res.headers.get('link') ?? ''); console.log(links.next); // → URL absoluta de la siguiente página console.log(links.last); // → URL absoluta de la última ``` ## Iterar todo ```ts theme={null} async function fetchAllMovements(accountId: string, apiKey: string) { const all: Movement[] = []; let url: string | null = `https://api.rail.cl/v1/accounts/${accountId}/movements?per_page=200`; while (url) { const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const page = await res.json(); all.push(...page); const links = parseLinkHeader(res.headers.get('link') ?? ''); url = links.next ?? null; } return all; } ``` ## Recomendación: usa `synced_since` en vez de `page` Para sync incremental, **no paginees por `page`**. Usa `synced_since` con un cursor monótono: ```bash theme={null} # Primer sync curl "...?per_page=200&page=1" # guarda el max(synced_at) de la response como cursor # Sucesivos curl "...?per_page=200&synced_since=" ``` Esto te asegura que **no pierdes movs retroactivos** que el banco posteó días después de la transacción real. `page` puede saltearte movs si entre dos calls se insertaron nuevos en medio. # Webhooks Source: https://docs.rail.cl/guides/webhooks Recibe eventos de Rail en tu backend con firma HMAC. ## Por qué webhooks En vez de polear `/v1/refresh_intents/{id}`, suscríbete a eventos. Rail te avisa cuando un sync termina, un link expira, o aparecen movs nuevos. ## Setup Crea un endpoint en el dashboard ([dashboard.rail.cl → Para desarrolladores → Webhooks](https://dashboard.rail.cl/developers/webhooks)) o via API: ```bash theme={null} curl https://api.rail.cl/v1/webhook_endpoints \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-app.com/webhooks/rail", "enabled_events": [ "link.refreshed", "link.expired", "refresh_intent.succeeded", "refresh_intent.requires_mfa" ] }' ``` Response incluye `secret` — guárdalo, lo necesitas para verificar firmas. ```json theme={null} { "object": "webhook_endpoint", "id": "we_xxx", "url": "https://tu-app.com/webhooks/rail", "secret": "whsec_abc123…", "enabled_events": ["link.refreshed", ...] } ``` ## Verificar firma Cada delivery viene con header `Rail-Signature: t=,v1=`. Verifica con HMAC-SHA256: ```ts theme={null} import { createHmac } from 'crypto'; function verifyRailSignature( rawBody: string, header: string, secret: string, ): boolean { const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=')), ); const timestamp = parts['t']; const expectedSig = parts['v1']; // Rechazar replays de más de 5min const age = Date.now() / 1000 - Number(timestamp); if (age > 300) return false; const payload = `${timestamp}.${rawBody}`; const actualSig = createHmac('sha256', secret) .update(payload) .digest('hex'); return actualSig === expectedSig; } ``` ```ts theme={null} // /webhooks/rail (Next.js route handler) export async function POST(req: Request) { const sig = req.headers.get('rail-signature') ?? ''; const body = await req.text(); if (!verifyRailSignature(body, sig, process.env.RAIL_WEBHOOK_SECRET!)) { return new Response('invalid signature', { status: 400 }); } const event = JSON.parse(body); // ... handle event return new Response('ok'); } ``` ## Eventos disponibles | Event type | Cuándo | | ----------------------------------- | --------------------------------------------------------- | | `link.created` | Después de exchange exitoso (link nace) | | `link.refreshed` | Sync OK — vino con movs y/o balances actualizados | | `link.expired` | 3 fallos consecutivos sin validar → user debe re-conectar | | `link.deleted` | Cliente borró el link | | `account.created` | Apareció una cuenta nueva en el link | | `account.removed` | Una cuenta desapareció del banco (cerrada) | | `refresh_intent.created` | Intent encolado | | `refresh_intent.succeeded` | Sync on-demand terminó OK | | `refresh_intent.failed` | Sync on-demand falló — ver `error_code` | | `refresh_intent.requires_mfa` | Banco pidió MFA — emite wt y abre widget | | `institution.maintenance_scheduled` | Un banco entra en ventana de mantención programada | | `institution.status_changed` | Rail pausa, desactiva o reactiva un banco | Los eventos `institution.*` sirven para mostrar en tus conexiones existentes cuándo un banco está fuera. Ver la guía [Estados de banco](/guides/estados-de-banco). ## Estructura del payload ```json theme={null} { "object": "event", "id": "evt_xxx", "type": "link.refreshed", "created_at": "2026-06-03T15:00:45Z", "data": { "object": "link", "id": "link_xxx", "status": "active", "last_synced_at": "2026-06-03T15:00:45Z", "accounts_count": 3 } } ``` `data` siempre contiene el recurso afectado en su forma actual (post-update). ## Retry policy Si tu endpoint devuelve != 2xx, Rail reintenta con backoff exponencial: | Intento | Delay | | ------- | --------- | | 1 | inmediato | | 2 | 1min | | 3 | 5min | | 4 | 30min | | 5 | 2h | | 6 | 6h | Después del 6to fallo, marcamos la delivery como `failed`. Puedes ver el historial en el dashboard o vía `GET /v1/webhook_deliveries`. ## Redeliver manual Si tu endpoint estuvo caído y quieres re-recibir un evento específico: ```bash theme={null} curl https://api.rail.cl/v1/webhook_deliveries/{id}/redeliver \ -X POST \ -H "Authorization: Bearer rail_sk_live_…" ``` ## Idempotency Tu handler debe ser **idempotente**: Rail puede entregar el mismo evento más de una vez (cuando hay timeouts, retries, redelivers). Usa el `event.id` como dedup key en tu DB. ```ts theme={null} const event = JSON.parse(body); const { rowCount } = await db.query( 'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [event.id], ); if (rowCount === 0) { // Ya procesado, skip return new Response('ok'); } // ... procesar event ``` # Widget Rail Connect Source: https://docs.rail.cl/guides/widget Integrar el widget para que tus users conecten sus bancos sin compartir credenciales con tu app. ## Por qué un widget Tu app **nunca debe ver** las credenciales bancarias del user. El widget de Rail Connect es un iframe que las captura directamente, las manda encriptadas a Rail, y te devuelve un `link_id` opaco. ## Flow completo ```mermaid theme={null} sequenceDiagram participant App as Tu frontend participant Backend as Tu backend participant Widget as widget.rail.cl participant Rail App->>Backend: "Quiero conectar BancoEstado" Backend->>Rail: POST /v1/widget_tokens { bank_id } Rail-->>Backend: { id: "wt_..." } Backend-->>App: wt_* App->>Widget: Rail.openWidget({ token, pk }) Widget->>Rail: validate Rail->>Widget: ok, mostrar form App->>Widget: user mete RUT + clave + MFA Widget->>Rail: sync inicial Rail-->>Widget: link_id, et_* Widget-->>App: onSuccess(et_*) App->>Backend: POST /callback { et_* } Backend->>Rail: POST /v1/exchange_tokens/{et} Rail-->>Backend: { link_id } ``` ## Setup ### 1. Backend — emite el widget token ```ts theme={null} // /api/widget-token (Next.js API route) import { NextResponse } from 'next/server'; export async function POST(req: Request) { const { bank_id } = await req.json(); const r = await fetch('https://api.rail.cl/v1/widget_tokens', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ bank_id }), }); const data = await r.json(); return NextResponse.json({ token: data.id }); } ``` **¿Cuenta de empresa?** Envía `holder_type: "business"` en el body del `POST /v1/widget_tokens`. El widget muestra el form de empresa (RUT empresa + RUT persona + clave) y filtra el picker a los bancos que soportan empresa. El frontend no cambia. Ver [Banca empresa](/guides/banca-empresa). ### 2. Frontend — abre el widget ```tsx theme={null} 'use client'; import Script from 'next/script'; import { useState } from 'react'; declare global { interface Window { Rail: { openWidget: (opts: { token: string; publishableKey: string; onSuccess: (et: string) => void; onExit?: (reason: string) => void; }) => void; }; } } export function ConnectBank() { async function handleClick() { const r = await fetch('/api/widget-token', { method: 'POST', body: JSON.stringify({ bank_id: 'banco_estado' }), }); const { token } = await r.json(); window.Rail.openWidget({ token, publishableKey: process.env.NEXT_PUBLIC_RAIL_KEY!, onSuccess: async (et) => { await fetch('/api/rail-callback', { method: 'POST', body: JSON.stringify({ exchange_token: et }), }); }, onExit: (reason) => console.log('user cerró:', reason), }); } return ( <> ``` ### Backend: canjea el exchange token por el link ```bash theme={null} curl https://api.rail.cl/v1/exchange_tokens/et_… \ -X POST \ -H "Authorization: Bearer rail_sk_test_…" ``` Response: ```json theme={null} { "object": "exchange_token", "link_id": "link_8a2f4c6e1b9d5037", "consumed_at": "2026-06-03T20:01:23Z" } ``` Guarda `link_id` asociado al usuario en tu DB — es la referencia para leer sus cuentas. ## 4. Lee las cuentas ```bash theme={null} curl "https://api.rail.cl/v1/accounts?link_id=link_8a2f4c6e1b9d5037" \ -H "Authorization: Bearer rail_sk_test_…" ``` Response: ```json theme={null} [ { "object": "account", "id": "acc_7d2f3a8c1e9b4502", "link_id": "link_8a2f4c6e1b9d5037", "name": "CuentaRUT", "type": "checking", "currency": "CLP", "balance": { "available": 500000, "current": 500000 }, "last_4": "5678" } ] ``` ## 5. Lee los movimientos ```bash theme={null} curl "https://api.rail.cl/v1/accounts/acc_7d2f3a8c1e9b4502/movements?per_page=10" \ -H "Authorization: Bearer rail_sk_test_…" ``` Response (array de movimientos): ```json theme={null} [ { "object": "movement", "id": "mov_6c8d1f3a9e5b2074", "amount": -30000, "currency": "CLP", "type": "card_payment", "description": "MERCADOLIBRE CL", "post_date": "2026-06-01T04:00:00+00:00" } ] ``` Los amounts son **bigint en minor units**. CLP no tiene centavos → el valor entero es directamente pesos. El signo es parte del valor (negativo = egreso). ## Próximos pasos Cómo forzar un sync con MFA async. Recibe eventos en tiempo real (link.refreshed, etc). # Rate limits Source: https://docs.rail.cl/rate-limits Límites por endpoint y cómo manejar respuestas 429. ## Límites por tipo de endpoint | Endpoint group | Rate limit | Burst | Notas | | ------------------------------------------------------------------------------- | ----------------------- | ----- | ------------------------------------------- | | **Read** (`GET /v1/links`, `/v1/accounts`, `/v1/accounts/{id}/movements`, etc) | 100 req/min por API key | 20 | Mismo bucket para todos los GETs | | **Sync write** (`POST /v1/refresh_intents`) | 10 req/min por API key | 5 | Independiente del cooldown de 5min por link | | **Widget tokens** (`POST /v1/widget_tokens`) | 30 req/min por API key | 10 | | | **Resources write** (`POST /v1/links/{id}/pause`, `DELETE /v1/links/{id}`, etc) | 30 req/min por API key | 10 | | | **Webhook deliveries** (read) | 60 req/min por API key | 20 | | Los límites son **por API key**, no por client. Si tienes `sk_live` + `sk_test`, cada una tiene su propio bucket. Las `pk_*` comparten bucket con su `sk_*` del mismo modo. ## Headers en cada response Todos los responses incluyen: ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1717434120 ``` | Header | Significa | | ----------------------- | ----------------------------------------------- | | `X-RateLimit-Limit` | Tu límite efectivo para este bucket (req/min) | | `X-RateLimit-Remaining` | Cuántos requests te quedan en la ventana actual | | `X-RateLimit-Reset` | Unix timestamp de cuándo se resetea el bucket | ## Cuando excedes — 429 ```http theme={null} HTTP/2 429 Too Many Requests Retry-After: 23 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1717434143 { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Excediste el límite de 100 req/min para esta API key. Reintenta en 23 segundos." } } ``` El header `Retry-After` te dice **cuántos segundos esperar antes de reintentar**. ## Manejo recomendado ```ts theme={null} async function railFetchWithBackoff( path: string, opts: RequestInit, maxRetries = 3, ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const res = await fetch(`https://api.rail.cl${path}`, opts); if (res.status !== 429) return res; if (attempt === maxRetries) return res; const retryAfter = Number(res.headers.get('retry-after') ?? '5'); await new Promise((r) => setTimeout(r, retryAfter * 1000)); } throw new Error('unreachable'); } ``` ## Patrones que disparan 429 | Anti-patrón | Por qué te bloquea | | -------------------------------------------------- | -------------------------------------------------------------------------------------- | | Polear `/v1/refresh_intents/{id}` cada 1s | Excedes 100 req/min en pocos minutos. Polear cada 5-10s en su lugar (o usar webhooks). | | Listar todos los movimientos sin paginar bien | Loop sin condición de salida = burst inmediato. | | Crear un `refresh_intent` por cada user en un cron | Hazlo escalonado o usa los syncs automáticos de Rail (cada 4h). | ## Pedir upgrade Si tu use case legítimo requiere límites más altos, contáctanos en [soporte@rail.cl](mailto:soporte@rail.cl) con: * Tu `client_id` (lo ves en el dashboard) * Caso de uso + volumen esperado * Endpoint/bucket específico Subimos rate limits caso por caso para clientes con producción estable. # Test mode (Sandbox) Source: https://docs.rail.cl/test-mode Integra Rail de punta a punta sin tocar un banco real — credenciales mágicas + data determinista. Test mode te permite construir y validar tu integración **sin conectar cuentas bancarias reales**. Las keys `rail_sk_test_…` / `rail_pk_test_…` operan contra un **banco fake** que devuelve cuentas y movimientos deterministas, con los mismos shapes, eventos y errores que producción. El modo se infiere del **prefijo de la key** — no hay parámetro aparte. Una `rail_sk_test_…` opera en sandbox; una `rail_sk_live_…` en producción. Los recursos de un modo **nunca** se ven desde el otro: un `sk_test` no puede leer un link `live` (y viceversa). ## 1. Obtén tus test keys En [dashboard.rail.cl](https://dashboard.rail.cl) → **Para desarrolladores → API keys**, crea una key con **Modo: Test**. El switch **Test/Live** de la barra superior cambia toda la vista del dashboard (conexiones, eventos, webhooks) al modo elegido. ```bash theme={null} # Todos los ejemplos usan tu secret key de test: export RAIL_SK=rail_sk_test_… ``` ## 2. Credenciales mágicas En test mode **no puedes conectar cuentas reales**. Usa el **password mágico** `rail-sandbox` + un **RUT mágico** que selecciona el escenario: | RUT | Password | Escenario | | -------------- | -------------- | ---------------------------------------------------------------------------------------------------- | | `11.111.111-1` | `rail-sandbox` | Persona — 1 cuenta vista + 1 tarjeta de crédito, \~50 movimientos | | `22.222.222-2` | `rail-sandbox` | Empresa — cuenta corriente CLP + USD (requiere `holder_type: business`) | | `33.333.333-3` | `rail-sandbox` | MFA challenge — el sync pide un código (mágico: `123456`) | | `44.444.444-4` | `rail-sandbox` | Credenciales inválidas — `rejected_credentials` | | `55.555.555-5` | `rail-sandbox` | Sin productos — 0 cuentas (cuenta vacía) | Cualquier otro RUT, o un password distinto de `rail-sandbox`, devuelve `credentials_invalid` — **nunca** se dispara un scraping real. La data es **determinista por link**: el mismo link devuelve siempre los mismos montos y glosas. ## 3. Conecta un banco fake (sin widget) En test mode puedes crear un link directo con `POST /v1/links` — el `bank_id` es cosmético (la data es fake igual). El sync arranca en background. ```bash theme={null} curl https://api.rail.cl/v1/links \ -X POST \ -H "Authorization: Bearer $RAIL_SK" \ -H "Content-Type: application/json" \ -d '{ "bank_id": "santander", "credentials": { "rut": "11.111.111-1", "password": "rail-sandbox" } }' ``` Response (el link arranca `pending` y sincroniza en background): ```json theme={null} { "object": "link", "id": "link_8a2f4c6e1b9d5037", "mode": "test", "institution": { "id": "cl_santander", "country": "cl" }, "status": "pending" } ``` ## 4. Espera el sync y lee la data El link emite `refresh_intent.succeeded` al terminar (unos segundos). Puedes consultar el `refresh_intent` o directamente leer las cuentas: ```bash theme={null} curl "https://api.rail.cl/v1/accounts?link_id=link_8a2f4c6e1b9d5037" \ -H "Authorization: Bearer $RAIL_SK" ``` ```json theme={null} [ { "object": "account", "id": "acc_7d2f3a8c1e9b4502", "name": "Cuenta Vista", "type": "checking", "currency": "CLP", "balance": { "available": 842000, "current": 842000 }, "mode": "test" }, { "object": "account", "id": "acc_1b9d5037a2f4c6e8", "name": "Tarjeta de Crédito Visa", "type": "credit_card", "currency": "CLP", "balance": { "available": 2100000, "limit": 3500000 }, "mode": "test" } ] ``` Los movimientos salen igual que en producción (montos bigint con signo, glosas chilenas realistas): ```bash theme={null} curl "https://api.rail.cl/v1/accounts/acc_7d2f3a8c1e9b4502/movements?per_page=5" \ -H "Authorization: Bearer $RAIL_SK" ``` ## 5. Prueba el flujo MFA El RUT `33.333.333-3` fuerza un **MFA challenge**: el `refresh_intent` pasa a `requires_mfa`. Responde con el código mágico `123456` para destrabarlo (cualquier otro código lo rechaza). Es el mismo flujo que un banco real con OTP — ideal para validar tu manejo de MFA async. Ver [MFA handling](/guides/mfa-handling). ## 6. Prueba tus webhooks Los webhook endpoints registrados en **modo test** reciben los eventos de tus links de sandbox (`refresh_intent.succeeded`, `link.credentials_invalid`, etc.) — aislados de tus endpoints `live`. Para validar tu receiver (firma HMAC + retry) **sin correr un sync**, dispara un evento de prueba a demanda: ```bash theme={null} curl https://api.rail.cl/v1/sandbox/events/fire \ -X POST \ -H "Authorization: Bearer $RAIL_SK" \ -H "Content-Type: application/json" \ -d '{ "type": "refresh_intent.succeeded" }' ``` El evento llega a tus endpoints de modo test con IDs centinela (`acc_00000000`) — es data de prueba, no consultable. Tipos disparables: `refresh_intent.succeeded`, `refresh_intent.failed`, `refresh_intent.requires_mfa`, `link.credentials_invalid`, `link.expired`. Ver [Webhooks](/guides/webhooks). ## Widget en test mode El widget de Rail Connect, inicializado con tu `rail_pk_test_…`, corre en **modo sandbox**: muestra un indicador **"Modo prueba"** y precarga las credenciales mágicas en el formulario para que las pruebes sin salir del flujo. ```html theme={null} ``` ## Pasar a producción Cuando tu integración funcione en test, cambia las keys `_test_` por `_live_` — **no cambia nada más de tu código** (las URLs, shapes y flujos son idénticos). El único cambio es que cada sync ahora consulta el banco real. Los 4 tipos de credenciales y el modelo test/live. Recibe los eventos de tus links de sandbox.