Skip to main content

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');
}

3. Frontend — escuchar notificación + abrir widget

'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

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

El user cierra el widget sin resolver

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.