RAILS
Sandbox

Webhooks e assinaturas

Como assinar cada requisição que você envia e como verificar cada webhook que você recebe. Os exemplos aparecem tal como são; também podem ser baixados.

Assinatura de requisições

Cada requisição leva a sua API key e uma assinatura HTTP (RFC 9421) feita com a sua chave privada. O detalhe de componentes e parâmetros está no guia de integração.

firma-pedidos.tsBaixar
/**
 * 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…",
// });

Verificação de webhooks

Verifique FCM-Signature sobre os bytes brutos antes de interpretar o JSON e rejeite um jti já processado.

verificar-webhooks.tsBaixar
/**
 * 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 e webhooks

  • Webhooks enxutos: o corpo traz o tipo e o id do objeto, não o objeto. Para o estado vigente, peça o objeto com o seu GET.
  • Conciliação por cursor: GET /v1/events?after_sequence=N. sequence é contíguo por entidade (1, 2, 3…): se você recebeu o 41 e depois o 43, falta o 42. Cada evento de dinheiro traz ledger_references, a referência estável aos lançamentos do ledger que o produziram.
  • Entrega pelo menos uma vez. Um webhook pode chegar repetido ou fora de ordem: deduplique pelo id do evento e ordene por sequence.
  • Novas tentativas: se o seu endpoint não responder 2xx em 10 s, tentamos mais 24 vezes (25 tentativas) durante cerca de 4,7 dias: 30 s, 1 min, 2 min, 5 min, 10 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 5 h e depois a cada 8 h, com ±20% de variação. Os eventos ficam em /v1/events por 90 dias.
  • Assinatura assimétrica: o cabeçalho FCM-Signature é um JWS compacto (JWT) assinado pelo Grip Rails com uma chave assimétrica guardada em KMS (EdDSA sobre Ed25519 ou ES256 sobre P-256; o JWKS diz qual). Claims: iss (https://api.fcmpayments.com), aud (o seu entity_id), iat, exp (iat + 300), jti (id da entrega, único), evt (id do evento) e body_sha256 (SHA-256 dos bytes brutos do corpo, em base64url sem preenchimento).
  • Como verificar: (1) leia kid e alg do cabeçalho do JWS e busque essa chave no JWKS (em cache; se o kid não estiver lá, atualize no máximo uma vez por minuto); (2) verifique a assinatura com o alg que o JWKS publica para esse kid, nunca com o que a requisição diz, se não coincidirem; (3) exija iat dentro dos últimos 5 minutos e exp futuro; (4) recalcule body_sha256 sobre o corpo bruto; (5) rejeite um jti já visto (guarde-o por 10 minutos); (6) exija aud igual ao seu entity_id.
  • JWKS: GET /.well-known/jwks.json, sem autenticação e com cache permitido. Uma chave nova é publicada 7 dias antes de começar a ser usada, e a antiga continua publicada 7 dias depois de deixar de ser usada.
  • O seu endpoint: HTTPS público com TLS 1.2 ou superior, sem redirecionamentos (não os seguimos), resposta em no máximo 10 s. Processe em segundo plano. A URL é registrada pelo Grip Rails no cadastro e é alterada mediante pedido verificado.

Chaves públicas (JWKS)

GET/.well-known/jwks.json#

Chaves públicas do Grip Rails para verificar webhooks

JWKS (RFC 7517) com as chaves públicas vigentes com que o Grip Rails assina os webhooks. Não exige autenticação. As chaves privadas vivem em KMS e não saem de lá.

Sem autenticação.

Respostas

200 Conjunto de chaves.
Cabeçalhos
  • Cache-Control string

    Pode ficar em cache por 5 minutos.

Esquema: Jwks

Uma chave 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 Você ultrapassou o limite de requisições por segundo.
Cabeçalhos
  • FCM-Request-Id RequestId

    Id da requisição no Grip Rails. Cite-o em qualquer consulta ao suporte.

  • Retry-After integer

    Segundos a esperar antes de tentar de novo.

Esquema: Error

500 Erro do Grip Rails. Repita com a mesma Idempotency-Key.
Cabeçalhos
  • FCM-Request-Id RequestId

    Id da requisição no Grip Rails. Cite-o em qualquer consulta ao suporte.

Esquema: Error

Tipos de evento

Exemplo completo

O percurso do início rápido de ponta a ponta no sandbox, em TypeScript (Node ≥ 22, sem dependências; comentários em espanhol). Precisa de firma-pedidos.ts na mesma pasta.

ejemplo-sandbox.tsBaixar
/**
 * 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;
});