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.

Por qué webhooks

En vez de polear /v1/refresh_intents/{id}, suscribite 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) o via API:
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 — guardalo, lo necesitás para verificar firmas.
{
  "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=<timestamp>,v1=<hex>. Verificá con HMAC-SHA256:
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;
}
// /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 typeCuándo
link.createdDespués de exchange exitoso (link nace)
link.refreshedSync OK — vino con movs y/o balances actualizados
link.expired3 fallos consecutivos sin validar → user debe re-conectar
link.deletedCliente borró el link
account.createdApareció una cuenta nueva en el link
account.removedUna cuenta desapareció del banco (cerrada)
refresh_intent.createdIntent encolado
refresh_intent.succeededSync on-demand terminó OK
refresh_intent.failedSync on-demand falló — ver error_code
refresh_intent.requires_mfaBanco pidió MFA — emití wt y abrí widget

Estructura del payload

{
  "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:
IntentoDelay
1inmediato
21min
35min
430min
52h
66h
Después del 6to fallo, marcamos la delivery como failed. Podés ver el historial en el dashboard o vía GET /v1/webhook_deliveries.

Redeliver manual

Si tu endpoint estuvo caído y querés re-recibir un evento específico:
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). Usá el event.id como dedup key en tu DB.
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