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

# 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=<timestamp>,v1=<hex>`. 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                  |

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