RAILS
Sandbox

Webhooks y firmas

Cómo firmar cada pedido que mandás y cómo verificar cada webhook que recibís. Los ejemplos se copian tal cual; también se descargan.

Firma de pedidos

Cada pedido lleva tu API key y una firma HTTP (RFC 9421) hecha con tu clave privada. El detalle de componentes y parámetros está en la guía de integración.

firma-pedidos.tsDescargar
/**
 * Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest). Node ≥ 22.
 *
 * La clave privada Ed25519 la carga quien llama y nunca se serializa en un pedido ni en un log. Cada pedido lleva un
 * `nonce` aleatorio nuevo (si repetís uno, FCM responde `signature_replayed`). Los componentes obligatorios son
 * `@method` y `@target-uri`; con cuerpo, además `content-type`, `content-digest` e `idempotency-key`; con
 * `FCM-Version`, `fcm-version`. El borde acepta también, si los cubrís, `date`, `content-length`, `@authority`,
 * `@path` y `@query` (lista cerrada). `Authorization` (la API key) va en el pedido pero NO se firma: si la cubrís, FCM
 * responde `signature_invalid` (la firma se tiene que poder verificar sin la API key).
 */
import { createHash, randomBytes, sign, type KeyObject } from "node:crypto";

type HeaderMap = Record<string, string>;

export interface RequestToSign {
  method: string;
  targetUri: string;
  authorization: string;
  privateKey: KeyObject;
  keyId: string;
  body?: Uint8Array;
  contentType?: string;
  idempotencyKey?: string;
  fcmVersion?: string;
  /** Componentes opcionales extra que el borde acepta. */
  extraComponents?: readonly ("date" | "content-length" | "@authority" | "@path" | "@query")[];
  /** Sólo pruebas: en producción, la hora actual. */
  created?: number;
  /** Sólo vectores de prueba: en producción se genera uno aleatorio por pedido. */
  nonce?: string;
  /** Si cubrís `date`, el valor que mandás en el encabezado. */
  date?: string;
}

export interface SignedRequest {
  headers: HeaderMap;
  signatureBase: string;
}

const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64");

/** 16 bytes aleatorios en base64url (22 caracteres): cumple `^[A-Za-z0-9_-]{16,64}$`. */
export function newNonce(): string {
  return randomBytes(16).toString("base64url");
}

function digestHeader(body: Uint8Array): string {
  return `sha-256=:${b64(createHash("sha256").update(body).digest())}:`;
}

function quote(component: string): string {
  return JSON.stringify(component);
}

function derived(component: string, targetUri: string): string | undefined {
  const url = new URL(targetUri);
  if (component === "@authority") return url.host.toLowerCase();
  if (component === "@path") return url.pathname === "" ? "/" : url.pathname;
  if (component === "@query") return url.search === "" ? "?" : url.search;
  return undefined;
}

export function signFcmRequest(request: RequestToSign): SignedRequest {
  const method = request.method.toUpperCase();
  const headers: HeaderMap = { Authorization: request.authorization };
  const values: Record<string, string> = { "@method": method, "@target-uri": request.targetUri };
  const components = ["@method", "@target-uri"];

  if (request.body !== undefined) {
    if (request.contentType === undefined || request.contentType === "") throw new Error("contentType is required when body is present");
    if (request.idempotencyKey === undefined || request.idempotencyKey === "") throw new Error("idempotencyKey is required for POST bodies");
    const digest = digestHeader(request.body);
    headers["Content-Type"] = request.contentType;
    headers["Content-Digest"] = digest;
    headers["Idempotency-Key"] = request.idempotencyKey;
    values["content-type"] = request.contentType;
    values["content-digest"] = digest;
    values["idempotency-key"] = request.idempotencyKey;
    components.push("content-type", "content-digest", "idempotency-key");
  }
  if (request.fcmVersion !== undefined && request.fcmVersion !== "") {
    headers["FCM-Version"] = request.fcmVersion;
    values["fcm-version"] = request.fcmVersion;
    components.push("fcm-version");
  }
  for (const extra of request.extraComponents ?? []) {
    if (extra === "date") {
      if (request.date === undefined) throw new Error("date is required to cover the date component");
      headers.Date = request.date;
      values.date = request.date;
    } else if (extra === "content-length") {
      const length = String(request.body?.byteLength ?? 0);
      headers["Content-Length"] = length;
      values["content-length"] = length;
    } else {
      values[extra] = derived(extra, request.targetUri) ?? "";
    }
    components.push(extra);
  }

  const created = request.created ?? Math.floor(Date.now() / 1000);
  const nonce = request.nonce ?? newNonce();
  const params = `(${components.map(quote).join(" ")});created=${created};nonce=${quote(nonce)};keyid=${quote(request.keyId)};alg="ed25519"`;
  const lines = components.map((component) => `${quote(component)}: ${values[component] ?? ""}`);
  lines.push(`${quote("@signature-params")}: ${params}`);
  const signatureBase = lines.join("\n");
  const signature = sign(null, Buffer.from(signatureBase, "utf8"), request.privateKey);
  headers["Signature-Input"] = `sig1=${params}`;
  headers.Signature = `sig1=:${signature.toString("base64")}:`;
  return { headers, signatureBase };
}

// Ejemplo (el cuerpo tiene que ser exactamente los bytes que se mandan):
// const body = Buffer.from(JSON.stringify({ account_number_id: "acn_…", amount: 1500000 }));
// const signed = signFcmRequest({
//   method: "POST",
//   targetUri: "https://empresas-sandbox.fcmpayments.com/v1/simulations/incoming_transfers",
//   authorization: "Bearer fcm_test_…",
//   privateKey,
//   keyId: "ck_…",
//   body,
//   contentType: "application/json",
//   idempotencyKey: "01JAT…",
// });

Verificar webhooks

Verificá FCM-Signature sobre los bytes crudos antes de parsear el JSON, y rechazá un jti ya procesado.

verificar-webhooks.tsDescargar
/**
 * Verificación de un webhook de FCM (JWS compacto) ANTES de parsear el cuerpo. Node ≥ 22, sin dependencias.
 *
 * Los seis pasos del contrato, en orden: clave por `kid` con el `alg` del JWKS → firma → `iat`/`exp` (5 min, con
 * 30 s de margen) → `body_sha256` sobre los bytes crudos → `aud` = tu `entity_id` → `jti` no visto (anti-replay,
 * OBLIGATORIO). Claims desconocidos se ignoran: FCM puede sumar claims sin aviso de versión.
 */
import { createHash, createPublicKey, timingSafeEqual, verify, type JsonWebKey, type KeyObject } from "node:crypto";

/** Cuánto se recuerda cada `jti` (el contrato pide al menos 10 minutos). */
export const REPLAY_TTL_SECONDS = 600;

export interface FcmJwk {
  kty: "OKP" | "EC";
  crv: "Ed25519" | "P-256";
  x: string;
  y?: string;
  kid: string;
  alg: "EdDSA" | "ES256";
  use: "sig";
}

export interface WebhookClaims {
  iss: string;
  aud: string;
  iat: number;
  exp: number;
  jti: string;
  evt: string;
  body_sha256: string;
}

/**
 * Anti-replay del `jti` (paso 5). `checkAndStore` devuelve false si ya lo vio en los últimos 10 minutos; si no, lo
 * guarda. En producción usá un almacén compartido y persistente (Postgres, Redis) y guardá el `jti` en la misma
 * transacción que el efecto de negocio.
 */
export interface ReplayGuard {
  checkAndStore(jti: string, nowUnix: number): boolean;
}

/** Guarda en memoria de un solo proceso, sólo para empezar: se pierde al reiniciar. */
export class InMemoryReplayGuard implements ReplayGuard {
  private readonly seen = new Map<string, number>();

  checkAndStore(jti: string, nowUnix: number): boolean {
    for (const [id, expires] of this.seen) if (expires <= nowUnix) this.seen.delete(id);
    if (this.seen.has(jti)) return false;
    this.seen.set(jti, nowUnix + REPLAY_TTL_SECONDS);
    return true;
  }
}

export interface VerifyWebhookOptions {
  token: string;
  rawBody: Uint8Array;
  jwks: readonly FcmJwk[];
  /** Tu `entity_id`: el `aud` tiene que ser éste. */
  entityId: string;
  /** Obligatorio. */
  replayGuard: ReplayGuard;
  issuer?: string;
  /** `FCM-Event-Id` y `FCM-Delivery-Id` de los encabezados, si los querés atar al token. */
  eventId?: string;
  deliveryId?: string;
  now?: number;
}

const CLOCK_SKEW_SECONDS = 30;
const MAX_AGE_SECONDS = 300;

function decodeSegment(value: string): Record<string, unknown> {
  const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("malformed JWS segment");
  return parsed as Record<string, unknown>;
}

function text(claims: Record<string, unknown>, name: string): string {
  const value = claims[name];
  if (typeof value !== "string" || value === "") throw new Error(`missing claim ${name}`);
  return value;
}

function integer(claims: Record<string, unknown>, name: string): number {
  const value = claims[name];
  if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`invalid claim ${name}`);
  return value;
}

/** Sólo los claims conocidos: uno nuevo no rompe la verificación. */
function knownClaims(claims: Record<string, unknown>): WebhookClaims {
  return {
    iss: text(claims, "iss"),
    aud: text(claims, "aud"),
    iat: integer(claims, "iat"),
    exp: integer(claims, "exp"),
    jti: text(claims, "jti"),
    evt: text(claims, "evt"),
    body_sha256: text(claims, "body_sha256"),
  };
}

function joseEcdsaToDer(signature: Buffer): Buffer {
  if (signature.length !== 64) throw new Error("ES256 JWS signature must be 64 bytes");
  const integerDer = (part: Buffer): Buffer => {
    let start = 0;
    while (start < part.length - 1 && part[start] === 0) start += 1;
    const trimmed = part.subarray(start);
    const first = trimmed[0] ?? 0;
    return (first & 0x80) !== 0 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed;
  };
  const r = integerDer(signature.subarray(0, 32));
  const s = integerDer(signature.subarray(32));
  const body = Buffer.concat([Buffer.from([0x02, r.length]), r, Buffer.from([0x02, s.length]), s]);
  return Buffer.concat([Buffer.from([0x30, body.length]), body]);
}

function publicKey(jwk: FcmJwk): KeyObject {
  const key: JsonWebKey = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, ...(jwk.y === undefined ? {} : { y: jwk.y }) };
  return createPublicKey({ key, format: "jwk" });
}

export function verifyFcmWebhook(options: VerifyWebhookOptions): WebhookClaims {
  const parts = options.token.split(".");
  const [headerPart, payloadPart, signaturePart] = parts;
  if (parts.length !== 3 || headerPart === undefined || payloadPart === undefined || signaturePart === undefined || parts.some((part) => part === "")) {
    throw new Error("malformed JWS");
  }
  const header = decodeSegment(headerPart);
  const alg = header.alg;
  if (alg !== "EdDSA" && alg !== "ES256") throw new Error("unsupported webhook algorithm");
  const jwk = options.jwks.find((candidate) => candidate.kid === header.kid);
  if (jwk === undefined || jwk.alg !== alg || jwk.use !== "sig") throw new Error("unknown or mismatched webhook key");
  const signingInput = Buffer.from(`${headerPart}.${payloadPart}`, "ascii");
  const encodedSignature = Buffer.from(signaturePart, "base64url");
  const signature = alg === "ES256" ? joseEcdsaToDer(encodedSignature) : encodedSignature;
  if (!verify(alg === "ES256" ? "sha256" : null, signingInput, publicKey(jwk), signature)) throw new Error("invalid webhook signature");

  const claims = knownClaims(decodeSegment(payloadPart));
  const now = options.now ?? Math.floor(Date.now() / 1000);
  if (claims.iat > now + CLOCK_SKEW_SECONDS || claims.iat < now - MAX_AGE_SECONDS - CLOCK_SKEW_SECONDS || claims.exp <= now - CLOCK_SKEW_SECONDS || claims.exp - claims.iat !== MAX_AGE_SECONDS) {
    throw new Error("expired or not-yet-valid webhook");
  }
  const expected = Buffer.from(createHash("sha256").update(options.rawBody).digest("base64url"));
  const received = Buffer.from(claims.body_sha256);
  if (expected.length !== received.length || !timingSafeEqual(expected, received)) throw new Error("webhook body hash mismatch");
  if (claims.iss !== (options.issuer ?? "https://api.fcmpayments.com")) throw new Error("invalid webhook issuer");
  if (claims.aud !== options.entityId) throw new Error("invalid webhook audience");
  if (options.eventId !== undefined && claims.evt !== options.eventId) throw new Error("webhook event id mismatch");
  if (options.deliveryId !== undefined && claims.jti !== options.deliveryId) throw new Error("webhook delivery id mismatch");
  // Último: un token inválido nunca consume el jti de una entrega legítima.
  if (!options.replayGuard.checkAndStore(claims.jti, now)) throw new Error("webhook replay");
  return claims;
}

Eventos y webhooks

  • Webhooks delgados: el cuerpo trae el tipo y el id del objeto, no el objeto. Para el estado vigente, pedí el objeto con su GET.
  • Conciliación por cursor: GET /v1/events?after_sequence=N. sequence es contiguo por entidad (1, 2, 3…): si recibiste el 41 y después el 43, te falta el 42. Cada evento de plata trae ledger_references, la referencia estable a los asientos del libro que lo produjeron.
  • Entrega al menos una vez. Un webhook puede llegar repetido o fuera de orden: deduplicá por id del evento y ordená por sequence.
  • Reintentos: si tu endpoint no responde 2xx en 10 s, reintentamos 24 veces más (25 intentos) durante unos 4,7 días: 30 s, 1 min, 2 min, 5 min, 10 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 5 h y después cada 8 h, con ±20 % de variación. Los eventos quedan en /v1/events durante 90 días.
  • Firma asimétrica: el header FCM-Signature es un JWS compacto (JWT) firmado por Grip Rails con una clave asimétrica guardada en KMS (EdDSA sobre Ed25519 o ES256 sobre P-256; el JWKS dice cuál). Claims: iss (https://api.fcmpayments.com), aud (tu entity_id), iat, exp (iat + 300), jti (id de entrega, único), evt (id del evento) y body_sha256 (SHA-256 de los bytes crudos del cuerpo, en base64url sin relleno).
  • Cómo verificar: (1) leé kid y alg del encabezado del JWS y buscá esa clave en el JWKS (cacheado; si el kid no está, refrescá como máximo una vez por minuto); (2) verificá la firma con el alg que publica el JWKS para ese kid, nunca con el que dice el pedido si no coincide; (3) exigí iat dentro de los últimos 5 minutos y exp futuro; (4) recalculá body_sha256 sobre el cuerpo crudo; (5) rechazá un jti ya visto (guardalo 10 minutos); (6) exigí aud igual a tu entity_id.
  • JWKS: GET /.well-known/jwks.json, sin autenticación y cacheable. Una clave nueva se publica 7 días antes de empezar a usarse y la vieja sigue publicada 7 días después de dejar de usarse.
  • Tu endpoint: HTTPS público con TLS 1.2 o superior, sin redirecciones (no las seguimos), respuesta en 10 s como máximo. Procesá en segundo plano. La URL la registra Grip Rails en el alta y se cambia por pedido verificado.

Claves públicas (JWKS)

GET/.well-known/jwks.json#

Claves públicas de Grip Rails para verificar webhooks

JWKS (RFC 7517) con las claves públicas vigentes con las que Grip Rails firma los webhooks. No requiere autenticación. Las claves privadas viven en KMS y no salen de ahí.

Sin autenticación.

Respuestas

200 Conjunto de claves.
Encabezados
  • Cache-Control string

    Se puede cachear 5 minutos.

Esquema: Jwks

Una clave P-256 vigente

application/json
{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
      "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
      "kid": "fcm-whk-2026-10",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}
429 Pasaste el límite de pedidos por segundo.
Encabezados
  • FCM-Request-Id RequestId

    Id del pedido en Grip Rails. Citalo en cualquier consulta a soporte.

  • Retry-After integer

    Segundos a esperar antes de reintentar.

Esquema: Error

500 Error de Grip Rails. Reintentá con la misma Idempotency-Key.
Encabezados
  • FCM-Request-Id RequestId

    Id del pedido en Grip Rails. Citalo en cualquier consulta a soporte.

Esquema: Error

Tipos de evento

Ejemplo completo

El recorrido del inicio rápido de punta a punta contra el sandbox, en TypeScript (Node ≥ 22, sin dependencias). Necesita firma-pedidos.ts en la misma carpeta.

ejemplo-sandbox.tsDescargar
/**
 * Ejemplo de punta a punta contra el SANDBOX de la API de clientes empresa. Node ≥ 22, sin dependencias. Hace lo que
 * tiene que hacer una integración, en el orden de inicio-rapido-sandbox.md:
 *   1. GET /v1/entity y GET /v1/account_numbers: la entidad y sus CVU.
 *   2. Ingreso: POST /v1/simulations/incoming_transfers (simula el aviso de COELSA) y se concilia por consulta periódica
 *      de GET /v1/events (cursor por `sequence`, sin filtro de tipo) + GET /v1/incoming_transfers/{id}.
 *   3. Egreso: POST /v1/counterparty_validations, POST /v1/outgoing_transfers con Idempotency-Key (201 `reserved`,
 *      202 `pending` o 422 `rejected`); el reintento con la MISMA clave devuelve la misma respuesta; se sigue el estado
 *      hasta `completed` (el riel simulado la acredita a los ~2 s).
 *   4. Un egreso cuyo importe termina en …99 queda `submitted` y se resuelve con POST …/rail_result.
 *   5. GET /v1/simulations/clock.
 *
 * Variables de entorno:
 *   FCM_BASE_URL          URL base que te dio FCM en el alta (por ejemplo https://empresas-sandbox.fcmpayments.com)
 *   FCM_API_KEY_FILE      archivo con tu API key fcm_test_… (o FCM_API_KEY con el valor)
 *   FCM_KEY_ID            el ck_… de tu clave pública registrada en FCM
 *   FCM_PRIVATE_KEY_FILE  tu clave privada Ed25519 (PEM PKCS#8). Nunca sale de tu sistema ni va a un log
 *   FCM_CURSOR_FILE       (opcional) dónde guardar el último `sequence` procesado; si no, arranca de 0 en cada corrida
 *   FCM_CONECTAR          (opcional) a dónde conectar en lugar de FCM_BASE_URL; se firma igual sobre
 *                         FCM_BASE_URL (la API arma @target-uri con su URL pública, no con el Host)
 *
 * Correrlo, con firma-pedidos.ts al lado: `npx tsx ejemplo-sandbox.ts`, o compilado
 * (`npx esbuild ejemplo-sandbox.ts --bundle --platform=node --format=esm --outfile=ejemplo.mjs && node ejemplo.mjs`).
 */
import { createPrivateKey, randomUUID, type KeyObject } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { signFcmRequest } from "./firma-pedidos.js";

// Valores mágicos del riel simulado (ver «Inicio rápido (sandbox)»).
const ALIAS_VALIDA = "sbx.valida";
const CUIT_TITULAR_VALIDA = "27000000006";

interface Configuracion {
  readonly baseUrl: string;
  readonly conectar: string;
  readonly apiKey: string;
  readonly keyId: string;
  readonly privateKey: KeyObject;
  readonly cursorFile: string | undefined;
}

interface Lista<T> {
  readonly data: readonly T[];
  readonly has_more: boolean;
}
interface Entidad {
  readonly id: string;
  readonly legal_name: string;
  readonly status: string;
}
interface NumeroDeCuenta {
  readonly id: string;
  readonly account_id: string;
  readonly cvu: string;
  readonly status: string;
  readonly is_primary: boolean;
}
interface CreditoEntrante {
  readonly id: string;
  readonly account_id: string;
  readonly amount: number;
  readonly net_amount: number;
  readonly payer: { readonly name: string | null } | null;
}
interface Salida {
  readonly id: string;
  readonly status: "pending" | "rejected" | "reserved" | "submitted" | "completed" | "failed";
  readonly status_reason: string | null;
  readonly amount: number;
  readonly total_debit_amount: number;
  readonly failure: { readonly code: string } | null;
}
interface Validacion {
  readonly id: string;
  readonly result: string;
}
interface Evento {
  readonly id: string;
  readonly type: string;
  readonly sequence: number;
  readonly data: { readonly object: string; readonly id: string };
}
interface ListaDeEventos {
  readonly data: readonly Evento[];
  readonly has_more: boolean;
}
interface Reloj {
  readonly now: string;
  readonly offset_seconds: number;
}
interface ErrorFcm {
  readonly error?: { readonly code?: string; readonly message?: string; readonly retryable?: boolean };
}
interface Respuesta {
  readonly status: number;
  readonly replayed: boolean;
  readonly body: unknown;
}

const esperar = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const pesos = (centavos: number): string => `$ ${(centavos / 100).toFixed(2)}`;

function leerConfiguracion(env: NodeJS.ProcessEnv): Configuracion {
  const obligatoria = (nombre: string): string => {
    const valor = env[nombre]?.trim();
    if (valor === undefined || valor === "") throw new Error(`falta ${nombre}`);
    return valor;
  };
  const baseUrl = obligatoria("FCM_BASE_URL").replace(/\/+$/, "");
  const apiKey = (env.FCM_API_KEY ?? readFileSync(obligatoria("FCM_API_KEY_FILE"), "utf8")).trim();
  if (!apiKey.startsWith("fcm_test_")) throw new Error("este ejemplo es para el sandbox: la API key tiene que ser fcm_test_…");
  return {
    baseUrl,
    conectar: (env.FCM_CONECTAR ?? baseUrl).replace(/\/+$/, ""),
    apiKey,
    keyId: obligatoria("FCM_KEY_ID"),
    privateKey: createPrivateKey(readFileSync(obligatoria("FCM_PRIVATE_KEY_FILE"), "utf8")),
    cursorFile: env.FCM_CURSOR_FILE,
  };
}

/**
 * Un pedido firmado. Cada intento lleva firma nueva (nonce y `created` nuevos) pero el MISMO cuerpo y la MISMA
 * Idempotency-Key: un 429, un 5xx reintentable o un corte de red se reintentan así, nunca con otra clave.
 */
async function pedir(config: Configuracion, metodo: "GET" | "POST", ruta: string, cuerpo?: unknown, idempotencyKey?: string): Promise<Respuesta> {
  const body = cuerpo === undefined ? undefined : Buffer.from(JSON.stringify(cuerpo));
  for (let intento = 1; ; intento += 1) {
    const firmado = signFcmRequest({
      method: metodo,
      targetUri: `${config.baseUrl}${ruta}`,
      authorization: `Bearer ${config.apiKey}`,
      privateKey: config.privateKey,
      keyId: config.keyId,
      ...(body === undefined ? {} : { body, contentType: "application/json", idempotencyKey: idempotencyKey ?? "" }),
    });
    let respuesta: Response;
    try {
      respuesta = await fetch(`${config.conectar}${ruta}`, { method: metodo, headers: firmado.headers, redirect: "manual", ...(body === undefined ? {} : { body }) });
    } catch (error) {
      if (intento >= 5) throw error;
      await esperar(1_000 * 2 ** (intento - 1));
      continue;
    }
    const texto = await respuesta.text();
    const parseado: unknown = texto === "" ? null : JSON.parse(texto);
    const error = (parseado as ErrorFcm | null)?.error;
    // 429 y 5xx reintentables; 409 idempotency_request_in_progress también (el primer intento sigue en curso).
    const reintentable = respuesta.status === 429 || (respuesta.status >= 500 && error?.retryable !== false) || (respuesta.status === 409 && error?.retryable === true);
    if (reintentable && intento < 5) {
      const retryAfter = Number(respuesta.headers.get("retry-after") ?? "0");
      await esperar(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 1_000 * 2 ** (intento - 1));
      continue;
    }
    return { status: respuesta.status, replayed: respuesta.headers.get("idempotent-replayed") === "true", body: parseado };
  }
}

function exigir<T>(respuesta: Respuesta, ...esperados: number[]): T {
  if (!esperados.includes(respuesta.status)) {
    const error = (respuesta.body as ErrorFcm | null)?.error;
    throw new Error(`esperaba HTTP ${esperados.join(" o ")} y llegó ${respuesta.status} ${error?.code ?? ""}: ${error?.message ?? ""}`);
  }
  return respuesta.body as T;
}

function leerCursor(archivo: string | undefined): number {
  if (archivo === undefined || !existsSync(archivo)) return 0;
  const valor = Number(readFileSync(archivo, "utf8").trim());
  return Number.isSafeInteger(valor) && valor >= 0 ? valor : 0;
}

/**
 * Ingreso por consulta periódica (sin webhooks): pide los eventos desde el último `sequence` procesado, en orden, SIN
 * filtro de tipo (así la secuencia es contigua por entidad y un hueco se detecta). Cada evento se procesa idempotente
 * por su `evt_` y el cursor se guarda DESPUÉS de procesarlo: en tu sistema, en la misma transacción que el efecto.
 * Un tipo de evento desconocido se ignora (pueden aparecer tipos nuevos sin cambio de versión).
 */
async function consumirEventos(config: Configuracion, desde: number, manejar: (evento: Evento) => Promise<void>): Promise<number> {
  let cursor = desde;
  for (;;) {
    const pagina = exigir<ListaDeEventos>(await pedir(config, "GET", `/v1/events?after_sequence=${cursor}&limit=100`), 200);
    for (const evento of pagina.data) {
      if (cursor !== 0 && evento.sequence !== cursor + 1) {
        throw new Error(`hueco en la secuencia de eventos: esperaba ${cursor + 1} y llegó ${evento.sequence} (avisale a FCM)`);
      }
      await manejar(evento);
      cursor = evento.sequence;
      if (config.cursorFile !== undefined) writeFileSync(config.cursorFile, `${cursor}\n`);
    }
    if (!pagina.has_more) return cursor;
  }
}

/** Sigue una transferencia saliente por GET hasta un estado esperado (lo mismo llega por webhook o por /v1/events). */
async function esperarEstado(config: Configuracion, id: string, estados: readonly Salida["status"][], segundos: number): Promise<Salida> {
  for (let i = 0; i < segundos * 2; i += 1) {
    const salida = exigir<Salida>(await pedir(config, "GET", `/v1/outgoing_transfers/${id}`), 200);
    if (estados.includes(salida.status)) return salida;
    await esperar(500);
  }
  throw new Error(`${id} no llegó a ${estados.join("/")} en ${segundos} s`);
}

async function principal(): Promise<void> {
  const config = leerConfiguracion(process.env);
  let cursor = leerCursor(config.cursorFile);

  // 1. La entidad y sus CVU.
  const entidad = exigir<Entidad>(await pedir(config, "GET", "/v1/entity"), 200);
  console.log(`entidad ${entidad.id} (${entidad.legal_name}), estado ${entidad.status}`);
  const numeros = exigir<Lista<NumeroDeCuenta>>(await pedir(config, "GET", "/v1/account_numbers"), 200);
  const activos = numeros.data.filter((n) => n.status === "active");
  const cvu = activos.find((n) => n.is_primary) ?? activos[0];
  if (cvu === undefined) throw new Error("la entidad no tiene un CVU activo");
  console.log(`CVU ${cvu.cvu} (${cvu.id}) de la cuenta ${cvu.account_id}`);

  // Ponerse al día: lo anterior ya está conciliado (o se concilia acá, idempotente por evt_).
  cursor = await consumirEventos(config, cursor, async () => undefined);

  // 2. Ingreso: un crédito simulado de $ 15.000 recorre el mismo camino que un aviso real de COELSA.
  const credito = exigir<CreditoEntrante>(
    await pedir(config, "POST", "/v1/simulations/incoming_transfers", { account_number_id: cvu.id, amount: 1_500_000, payer: { name: "PAGADOR DE PRUEBA" }, concept: "VAR" }, randomUUID()),
    201,
  );
  console.log(`crédito simulado ${credito.id}: ${pesos(credito.amount)}, neto de comisión ${pesos(credito.net_amount)}`);
  let conciliado = false;
  for (let vuelta = 0; vuelta < 15 && !conciliado; vuelta += 1) {
    cursor = await consumirEventos(config, cursor, async (evento) => {
      if (evento.type !== "transfer.incoming.posted") {
        console.log(`  evento ${evento.sequence} ${evento.type} ${evento.data.id}`);
        return;
      }
      // El evento es delgado: el detalle (importe, comisión, pagador) se pide aparte.
      const itr = exigir<CreditoEntrante>(await pedir(config, "GET", `/v1/incoming_transfers/${evento.data.id}`), 200);
      console.log(`  evento ${evento.sequence} ${evento.type}: ${itr.id} ${pesos(itr.amount)} de ${itr.payer?.name ?? "?"}`);
      if (itr.id === credito.id) conciliado = true;
    });
    if (!conciliado) await esperar(2_000);
  }
  if (!conciliado) throw new Error(`el crédito ${credito.id} no apareció en /v1/events`);

  // 3. Egreso: validar el destino, ordenar con Idempotency-Key y seguir el estado.
  const validacion = exigir<Validacion>(
    await pedir(config, "POST", "/v1/counterparty_validations", { destination: { type: "alias", value: ALIAS_VALIDA }, expected_cuit: CUIT_TITULAR_VALIDA }, randomUUID()),
    201,
  );
  if (validacion.result !== "valid") throw new Error(`la validación de ${ALIAS_VALIDA} dio ${validacion.result}`);
  const orden = {
    account_id: credito.account_id,
    amount: 100_000,
    currency: "ARS",
    destination: { type: "alias", value: ALIAS_VALIDA },
    expected_counterparty_cuit: CUIT_TITULAR_VALIDA,
    counterparty_validation_id: validacion.id,
    external_reference: `ejemplo-${Date.now()}`,
  };
  const clave = randomUUID(); // una por intención de pago: se guarda ANTES de mandar el pedido
  const creada = await pedir(config, "POST", "/v1/outgoing_transfers", orden, clave);
  if (creada.status === 422) throw new Error(`rechazada por la autorización: ${(creada.body as ErrorFcm).error?.code ?? "?"}`);
  const salida = exigir<Salida>(creada, 201, 202);
  console.log(`egreso ${salida.id}: HTTP ${creada.status} ${salida.status}, débito total ${pesos(salida.total_debit_amount)}`);
  const repetida = await pedir(config, "POST", "/v1/outgoing_transfers", orden, clave);
  if (repetida.status !== creada.status || !repetida.replayed || (repetida.body as Salida).id !== salida.id) {
    throw new Error("el reintento con la misma Idempotency-Key no devolvió la respuesta guardada");
  }
  console.log(`  reintento con la misma clave: HTTP ${repetida.status}, Idempotent-Replayed: true, misma transferencia`);
  const final = await esperarEstado(config, salida.id, ["completed", "failed"], 30);
  if (final.status !== "completed") throw new Error(`el egreso terminó ${final.status} (${final.failure?.code ?? "?"})`);
  console.log(`  ${salida.id} completed`);

  // 4. Un importe que termina en …99 queda submitted hasta que el resultado se inyecta a mano.
  const enEspera = exigir<Salida>(await pedir(config, "POST", "/v1/outgoing_transfers", { ...orden, amount: 1_099, counterparty_validation_id: undefined, external_reference: `ejemplo-99-${Date.now()}` }, randomUUID()), 201, 202);
  await esperarEstado(config, enEspera.id, ["submitted"], 30);
  const resuelta = exigir<Salida>(await pedir(config, "POST", `/v1/simulations/outgoing_transfers/${enEspera.id}/rail_result`, { outcome: "completed" }, randomUUID()), 200);
  if (resuelta.status !== "completed") throw new Error(`rail_result dejó ${enEspera.id} en ${resuelta.status}`);
  console.log(`egreso …99 ${enEspera.id}: submitted → rail_result → completed`);

  // 5. El reloj de simulación de la entidad (topes diarios y mensuales, reintentos de webhooks).
  const reloj = exigir<Reloj>(await pedir(config, "GET", "/v1/simulations/clock"), 200);
  console.log(`reloj de la entidad: ${reloj.now} (adelantado ${reloj.offset_seconds} s)`);

  // Los egresos también dejaron sus eventos (reserved, completed): se concilian con el mismo cursor.
  cursor = await consumirEventos(config, cursor, async (evento) => {
    console.log(`  evento ${evento.sequence} ${evento.type} ${evento.data.id}`);
  });
  console.log(`listo: último sequence procesado ${cursor}`);
}

principal().catch((error: unknown) => {
  console.error(`ejemplo-sandbox: ${error instanceof Error ? error.message : String(error)}`);
  process.exitCode = 1;
});