# 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 (
<>
>
);
}
```
### 3. Backend — canjea el exchange token
```ts theme={null}
// /api/rail-callback
export async function POST(req: Request) {
const { exchange_token } = await req.json();
const r = await fetch(`https://api.rail.cl/v1/exchange_tokens/${exchange_token}`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}` },
});
const { link_id } = await r.json();
// Guardalo en tu DB asociado al user logueado
await db.from('user_bank_links').insert({
user_id: session.user.id,
link_id,
});
return NextResponse.json({ ok: true });
}
```
## Configuración avanzada
### Pre-fill de RUT y nombre
Si ya conoces el RUT del user (porque está logueado en tu app), pre-cárgalo en el widget. Esto ahorra fricción y mejora conversión.
```ts theme={null}
window.Rail.openWidget({
token: 'wt_…',
publishableKey: 'rail_pk_live_…',
username: {
value: '12345678-9',
editable: false, // true = el user puede cambiarlo
},
holderId: {
value: '12345678-9',
editable: true,
},
onSuccess: (et) => {},
});
```
| Param | Tipo | Significa |
| ------------------- | ------- | --------------------------------------------------------------------------- |
| `username.value` | string | RUT con guión (`XX.XXX.XXX-X` o sin puntos) |
| `username.editable` | boolean | Si `false`, el campo aparece bloqueado. Default `true`. |
| `holderId.value` | string | RUT del titular si difiere del username (raro). Default = `username.value`. |
| `holderId.editable` | boolean | Default `true`. |
Pre-fillear el RUT mejora la conversión entre 2-4% según nuestros tests internos. Si tu app ya lo conoce, úsalo siempre con `editable: false`.
### Appearance (dark mode)
```ts theme={null}
window.Rail.openWidget({
token: 'wt_…',
publishableKey: 'rail_pk_live_…',
appearance: 'dark', // 'light' | 'dark' | 'auto'
onSuccess: (et) => {},
});
```
`'auto'` respeta el `prefers-color-scheme` del browser del user.
### Locale
```ts theme={null}
window.Rail.openWidget({
// ...
locale: 'es-CL', // 'es-CL' (default) | 'es' | 'en'
});
```
Hoy solo soportamos español chileno y español neutro. Inglés en roadmap.
### Soporte en el widget
Cuando una conexión falla en algo que tu usuario no puede resolver solo, el widget
le ofrece **tu** contacto de soporte (nunca el de Rail). Se configura una vez en el
dashboard, en **Configuración → Empresa → Soporte en el widget**: WhatsApp, email y/o
centro de ayuda. Si no cargás ninguno, el widget no muestra nada.
El link no aparece en todos los errores, a propósito:
| Situación | Qué muestra el widget |
| --------------------------------------------- | ----------------------------------------------------------------- |
| Banco con intermitencia o en mantención | "Se reconecta solo" — sin soporte (se arregla solo) |
| Clave incorrecta | "Revisá tus datos" — sin soporte (lo resuelve el usuario) |
| Clave bloqueada / hay que crearla en el banco | Link al portal del banco — sin soporte (la acción es en el banco) |
| El resto (falla sin salida) | Link a tu soporte |
Cuando el canal es WhatsApp, el mensaje va prellenado con el banco, el nombre de tu
app y un código de referencia del intento — que es lo primero que tu equipo necesita
y lo que el usuario no sabe contestar. Nunca incluye RUT ni nada de la clave.
Si preferís manejarlo por tu cuenta, no cargues ningún contacto: el widget te sigue
avisando cada falla por `onEvent` / `onError` con el `code` y el `institution_id`,
y mostrás tu propia UI.
## Callbacks
| Callback | Cuándo dispara | Recibe |
| ---------------- | --------------------------- | -------------------------------------------------------------------------- |
| `onSuccess(et)` | User conectó OK | `et_*` para canjear server-side |
| `onExit(reason)` | User cerró sin terminar | `'user_close'` \| `'invalid_credentials'` \| `'bank_error'` \| `'timeout'` |
| `onEvent(event)` | Cualquier evento del widget | Ver tabla abajo |
### Listen to widget events
Para analytics o tracking del funnel, suscríbete a todos los eventos del widget:
```ts theme={null}
window.Rail.openWidget({
// ...
onEvent: (event) => {
// Envía a tu analytics — Mixpanel, Segment, PostHog, etc.
analytics.track(`rail_widget_${event.type}`, event.payload);
},
});
```
| Event type | Cuándo |
| ----------------------- | --------------------------------- |
| `OPEN` | Widget se abrió |
| `BANK_SELECTED` | User eligió un banco |
| `CREDENTIALS_SUBMITTED` | User submiteó RUT + clave |
| `MFA_PROMPTED` | El banco pidió MFA |
| `MFA_SUBMITTED` | User submiteó el código MFA |
| `LINK_CREATED` | El link fue creado OK |
| `EXIT` | Widget se cerró (cualquier razón) |
| `ERROR` | Error en algún paso |
**Estos eventos son solo para UX/analytics.** No los uses para confirmar que el link fue creado o que el sync funcionó — para eso usa webhooks o el `onSuccess` con el exchange token. Los eventos del widget pueden perderse (user cierra el browser, network) y no garantizan delivery.
## Re-abrir el widget para MFA
Cuando un sync requiere MFA async, el `refresh_intent.requires_mfa.widget_token` contiene un `ri_*_sec_*` que abres igual que un `wt_*`:
```ts theme={null}
window.Rail.openWidget({
token: refreshIntent.requires_mfa.widget_token,
publishableKey: process.env.NEXT_PUBLIC_RAIL_KEY!,
onSuccess: () => {
// MFA resuelto, el sync sigue server-side
},
});
```
No hace falta `onSuccess` con `et_*` — el link ya existe, solo estamos resolviendo el MFA.
# Introducción
Source: https://docs.rail.cl/introduction
Rail es un agregador bancario chileno read-only. Conecta las cuentas bancarias de tus usuarios con un par de llamadas HTTP.
## ¿Qué es Rail?
Rail es una API que te deja leer **cuentas y movimientos bancarios chilenos** —de personas y de empresas— de forma programática. Diseñada para builders chilenos que necesitan agregar data financiera de sus usuarios.
Es **read-only por diseño**: no iniciamos transferencias ni pagos. Solo lectura.
¿Banca empresa? Es el mismo flujo, distingues con `holder_type`. Ver [Banca empresa](/guides/banca-empresa).
## Bancos soportados
Cuentas vista, corrientes y tarjetas de crédito.
CuentaRUT, cuentas vista, ahorro plazo, ahorro vivienda.
Cuentas corrientes y tarjetas.
Cuentas corrientes y tarjetas.
CMR Visa.
Itaú, BICE, Security, Scotiabank.
## Cómo funciona
```mermaid theme={null}
sequenceDiagram
actor User
participant App as Tu App
participant Widget as Rail Widget
participant API as Rail API
participant Bank as Banco
User->>App: Quiero conectar mi banco
App->>API: POST /v1/widget_tokens
API-->>App: wt_xxx
App->>Widget: open(wt_xxx)
User->>Widget: RUT + clave + MFA
Widget->>API: validate
API->>Bank: scrape session
Bank-->>API: cuentas + movs
API-->>Widget: link_xxx (vía et_xxx)
Widget-->>App: et_xxx
App->>API: POST /v1/exchange_tokens/et_xxx
API-->>App: link_id
App->>API: GET /v1/accounts?link_id=...
API-->>App: cuentas
```
## Próximo paso
Conecta tu primera cuenta en 5 minutos.
Modelo de 4 credenciales: sk, pk, wt, et.
# Quickstart
Source: https://docs.rail.cl/quickstart
Conecta una cuenta bancaria y lee movimientos en 5 minutos.
## 1. Obtén tus API keys
1. Ve a [dashboard.rail.cl](https://dashboard.rail.cl) y crea una cuenta.
2. En **Para desarrolladores → API keys**, vas a tener 4 keys:
* `rail_sk_test_…` — secret key de test
* `rail_pk_test_…` — publishable key de test
* `rail_sk_live_…` — secret key de live
* `rail_pk_live_…` — publishable key de live
Las `sk_*` van solo en tu backend. Nunca en frontend ni en git.
## 2. Lista los links existentes
Un **link** es la conexión de un usuario tuyo con su banco. Para empezar listamos los que ya existen (al inicio, vacío):
```bash theme={null}
curl https://api.rail.cl/v1/links \
-H "Authorization: Bearer rail_sk_test_…"
```
Response:
```json theme={null}
[]
```
## 3. Crea tu primer link con el widget
El widget de Rail Connect maneja el flow de credenciales y MFA sin que tu app las vea nunca.
### Backend: emite un widget token
```bash theme={null}
curl https://api.rail.cl/v1/widget_tokens \
-X POST \
-H "Authorization: Bearer rail_sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"bank_id": "banco_estado"
}'
```
Response:
```json theme={null}
{
"object": "widget_token",
"id": "wt_a1b2c3d4_sec_…",
"expires_at": "2026-06-03T20:00:00Z"
}
```
Para conectar una **cuenta de empresa**, agrega `"holder_type": "business"` al
body. El widget pide RUT empresa + RUT persona + clave del portal. Ver
[Banca empresa](/guides/banca-empresa).
### Frontend: abre el widget
```html theme={null}
```
### 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.