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

# Manejo de MFA

> 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<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
  }
}
```

<Warning>
  No polees más rápido que cada 5 segundos. Si excedes 100 req/min vas a tirar 429. Ver [Rate limits](/rate-limits).
</Warning>

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