RAILS
Sandbox

Webhooks and signatures

How to sign every request you send and how to verify every webhook you receive. The examples are shown as is; you can also download them.

Signing requests

Every request carries your API key and an HTTP signature (RFC 9421) made with your private key. The details of components and parameters are in the integration guide.

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

Verifying webhooks

Verify FCM-Signature over the raw bytes before parsing the JSON, and reject a jti you have already processed.

verificar-webhooks.tsDownload
/**
 * 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;
}

Events and webhooks

  • Thin webhooks: the body carries the type and the id of the object, not the object. For the current state, fetch the object with its GET.
  • Cursor-based reconciliation: GET /v1/events?after_sequence=N. sequence is contiguous per entity (1, 2, 3…): if you received 41 and then 43, you are missing 42. Every money event carries ledger_references, the stable reference to the ledger entries that produced it.
  • At-least-once delivery. A webhook can arrive more than once or out of order: deduplicate by the event id and sort by sequence.
  • Retries: if your endpoint does not respond 2xx within 10 s, we retry 24 more times (25 attempts) over about 4.7 days: 30 s, 1 min, 2 min, 5 min, 10 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 5 h and then every 8 h, with ±20% jitter. Events stay in /v1/events for 90 days.
  • Asymmetric signature: the FCM-Signature header is a compact JWS (JWT) signed by Grip Rails with an asymmetric key kept in KMS (EdDSA over Ed25519 or ES256 over P-256; the JWKS says which). Claims: iss (https://api.fcmpayments.com), aud (your entity_id), iat, exp (iat + 300), jti (delivery id, unique), evt (event id) and body_sha256 (SHA-256 of the raw body bytes, base64url without padding).
  • How to verify: (1) read kid and alg from the JWS header and look up that key in the JWKS (cached; if the kid is not there, refresh at most once per minute); (2) verify the signature with the alg the JWKS publishes for that kid, never with the one the request states if they differ; (3) require iat within the last 5 minutes and an exp in the future; (4) recompute body_sha256 over the raw body; (5) reject a jti you have already seen (keep it for 10 minutes); (6) require aud to equal your entity_id.
  • JWKS: GET /.well-known/jwks.json, no authentication, cacheable. A new key is published 7 days before it starts being used, and the old one stays published for 7 days after it stops being used.
  • Your endpoint: public HTTPS with TLS 1.2 or higher, no redirects (we do not follow them), response within 10 s at most. Process in the background. Grip Rails registers the URL at onboarding, and it is changed through a verified request.

Public keys (JWKS)

GET/.well-known/jwks.json#

Grip Rails public keys for verifying webhooks

JWKS (RFC 7517) with the current public keys with which Grip Rails signs webhooks. No authentication required. The private keys live in KMS and never leave it.

No authentication.

Responses

200 Key set.
Headers
  • Cache-Control string

    Can be cached for 5 minutes.

Schema: Jwks

A current P-256 key

application/json
{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
      "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
      "kid": "fcm-whk-2026-10",
      "alg": "ES256",
      "use": "sig"
    }
  ]
}
429 You exceeded the limit of requests per second.
Headers
  • FCM-Request-Id RequestId

    Id of the request at Grip Rails. Quote it in any support inquiry.

  • Retry-After integer

    Seconds to wait before retrying.

Schema: Error

500 A Grip Rails error. Retry with the same Idempotency-Key.
Headers
  • FCM-Request-Id RequestId

    Id of the request at Grip Rails. Quote it in any support inquiry.

Schema: Error

Event types

End-to-end example

The quickstart walkthrough end to end against the sandbox, in TypeScript (Node ≥ 22, no dependencies; comments in Spanish). It needs firma-pedidos.ts in the same folder.

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