Documentation Index
Fetch the complete documentation index at: https://docs.rail.cl/llms.txt
Use this file to discover all available pages before exploring further.
Algunos bancos chilenos (Santander, BCI, BancoEstado a veces) requieren clave dinámica en cada sync. Eso significa que cuando creás 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
Paso a paso
1. Backend — creá el refresh intent
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
// /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;
// Guardá 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,
});
// Notificá 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');
}
'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 mandó 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.
// Mostrá un loader hasta que el webhook succeeded llegue.
setSyncing(true);
},
onExit: (reason) => {
if (reason === 'timeout') {
alert('El código expiró. Intentalo de nuevo.');
}
},
});
})
.subscribe();
return () => sub.unsubscribe();
}, [linkId]);
}
Alternativa: polling (sin webhooks)
Si no tenés webhooks setup todavía, podés polear el intent:
async function pollIntent(intentId: string): Promise<RefreshIntent> {
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 poleés más rápido que cada 5 segundos. Si excedés 100 req/min vas a tirar 429. Ver Rate limits.
Casos edge
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.
Mostrale 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. Pedile que reintente.
onExit(reason) recibe 'user_close'. El intent queda en requires_mfa hasta que expire (10min). Si querés cancelar antes:
await fetch(`https://api.rail.cl/v1/refresh_intents/${intentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.RAIL_SECRET_KEY}` },
});
UX recomendada
- Mostrá un estado claro: “Esperando código del banco” mientras está en
requires_mfa.
- Botón cancelar: dejá al user salir del flow sin que quede colgado.
- Texto del banco: si sabés que el banco pide MFA en cada sync (Santander, BCI), avisalo upfront: “Tu banco va a pedirte un código cada vez que sincronicemos”.
- Recordatorio app del banco: “Abrí tu app de Santander para ver el código”.
- Timeout visual: countdown del
expires_at para que el user sepa cuánto le queda.