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 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…",
// });
"""Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest).
Requiere ``cryptography``. Quien llama carga la Ed25519PrivateKey desde su propio almacén de claves; este módulo nunca
escribe material privado. Cada pedido lleva un ``nonce`` aleatorio nuevo (si repetís uno, FCM responde
``signature_replayed``). Obligatorios: ``@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``.
"""
from __future__ import annotations
import base64
import hashlib
import json
import secrets
import time
from urllib.parse import urlsplit
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
EXTRA_COMPONENTS = ("date", "content-length", "@authority", "@path", "@query")
def _b64(value: bytes) -> str:
return base64.b64encode(value).decode("ascii")
def new_nonce() -> str:
"""16 bytes aleatorios en base64url (22 caracteres)."""
return secrets.token_urlsafe(16)
def _digest_header(body: bytes) -> str:
return f"sha-256=:{_b64(hashlib.sha256(body).digest())}:"
def _quote(value: str) -> str:
# Los nombres de componentes y los nonces son ASCII: el JSON da la forma quoted-string del RFC 9421.
return json.dumps(value, separators=(",", ":"))
def _derived(component: str, target_uri: str) -> str:
parts = urlsplit(target_uri)
if component == "@authority":
return parts.netloc.lower()
if component == "@path":
return parts.path or "/"
return f"?{parts.query}" if parts.query else "?"
def sign_fcm_request(
*,
method: str,
target_uri: str,
authorization: str,
private_key: Ed25519PrivateKey,
key_id: str,
body: bytes | None = None,
content_type: str | None = None,
idempotency_key: str | None = None,
fcm_version: str | None = None,
extra_components: tuple[str, ...] = (),
date: str | None = None,
created: int | None = None,
nonce: str | None = None,
) -> tuple[dict[str, str], str]:
"""``created`` y ``nonce`` fijos sólo para vectores de prueba: en producción, hora actual y nonce aleatorio."""
headers: dict[str, str] = {"Authorization": authorization}
values: dict[str, str] = {"@method": method.upper(), "@target-uri": target_uri}
components = ["@method", "@target-uri"]
if body is not None:
if content_type is None or idempotency_key is None:
raise ValueError("body requests require content_type and idempotency_key")
digest = _digest_header(body)
headers.update({"Content-Type": content_type, "Content-Digest": digest, "Idempotency-Key": idempotency_key})
values.update({"content-type": content_type, "content-digest": digest, "idempotency-key": idempotency_key})
components.extend(("content-type", "content-digest", "idempotency-key"))
if fcm_version is not None:
headers["FCM-Version"] = fcm_version
values["fcm-version"] = fcm_version
components.append("fcm-version")
for extra in extra_components:
if extra not in EXTRA_COMPONENTS:
raise ValueError(f"component {extra} is not accepted by FCM")
if extra == "date":
if date is None:
raise ValueError("date is required to cover the date component")
headers["Date"] = date
values["date"] = date
elif extra == "content-length":
length = str(len(body or b""))
headers["Content-Length"] = length
values["content-length"] = length
else:
values[extra] = _derived(extra, target_uri)
components.append(extra)
created = int(time.time()) if created is None else created
nonce = new_nonce() if nonce is None else nonce
params = (
f"({' '.join(_quote(component) for component in components)})"
f";created={created};nonce={_quote(nonce)};keyid={_quote(key_id)};alg=\"ed25519\""
)
lines = [f"{_quote(component)}: {values[component]}" for component in components]
signature_base = "\n".join((*lines, f'{_quote("@signature-params")}: {params}'))
signature = private_key.sign(signature_base.encode("utf-8"))
headers["Signature-Input"] = f"sig1={params}"
headers["Signature"] = f"sig1=:{_b64(signature)}:"
return headers, signature_base
# Ejemplo:
# body = json.dumps({"account_number_id": "acn_…", "amount": 1500000}, separators=(",", ":")).encode()
# headers, _ = sign_fcm_request(
# method="POST", target_uri="https://empresas-sandbox.fcmpayments.com/v1/simulations/incoming_transfers",
# authorization="Bearer fcm_test_…", private_key=private_key, key_id="ck_…",
# body=body, content_type="application/json", idempotency_key="01JAT…",
# )
// Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest).
// Quien llama es dueño de la clave privada Ed25519 y la mantiene fuera de los logs. Cada pedido lleva un nonce
// aleatorio nuevo (si repetís uno, FCM responde signature_replayed). Obligatorios: @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.
package integradores
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
type RequestToSign struct {
Method string
TargetURI string
Authorization string
PrivateKey ed25519.PrivateKey
KeyID string
Body []byte
ContentType string
IdempotencyKey string
FCMVersion string
ExtraComponents []string // date, content-length, @authority, @path, @query
Date string // valor del encabezado Date si se cubre "date"
Created int64 // sólo pruebas; 0 = hora actual
Nonce string // sólo vectores de prueba; vacío = aleatorio
}
type SignedRequest struct {
Headers map[string]string
SignatureBase string
}
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
// NewNonce devuelve 16 bytes aleatorios en base64url (22 caracteres).
func NewNonce() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func digestHeader(body []byte) string {
digest := sha256.Sum256(body)
return fmt.Sprintf("sha-256=:%s:", b64(digest[:]))
}
func quote(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return `""`
}
return string(encoded)
}
func derived(component, targetURI string) (string, error) {
parsed, err := url.Parse(targetURI)
if err != nil {
return "", fmt.Errorf("parse target URI: %w", err)
}
switch component {
case "@authority":
return strings.ToLower(parsed.Host), nil
case "@path":
if parsed.EscapedPath() == "" {
return "/", nil
}
return parsed.EscapedPath(), nil
default:
if parsed.RawQuery == "" {
return "?", nil
}
return "?" + parsed.RawQuery, nil
}
}
// SignFcmRequest arma la base de firma exacta del RFC 9421. Body es nil en un GET; todo POST con cuerpo lleva
// además IdempotencyKey.
func SignFcmRequest(request RequestToSign) (SignedRequest, error) {
if len(request.PrivateKey) != ed25519.PrivateKeySize {
return SignedRequest{}, fmt.Errorf("private key must be an Ed25519 key")
}
method := strings.ToUpper(request.Method)
headers := map[string]string{"Authorization": request.Authorization}
values := map[string]string{"@method": method, "@target-uri": request.TargetURI}
components := []string{"@method", "@target-uri"}
if request.Body != nil {
if request.ContentType == "" || request.IdempotencyKey == "" {
return SignedRequest{}, fmt.Errorf("body requests require content type and idempotency key")
}
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 = append(components, "content-type", "content-digest", "idempotency-key")
}
if request.FCMVersion != "" {
headers["FCM-Version"] = request.FCMVersion
values["fcm-version"] = request.FCMVersion
components = append(components, "fcm-version")
}
for _, extra := range request.ExtraComponents {
switch extra {
case "date":
if request.Date == "" {
return SignedRequest{}, fmt.Errorf("date is required to cover the date component")
}
headers["Date"] = request.Date
values["date"] = request.Date
case "content-length":
length := strconv.Itoa(len(request.Body))
headers["Content-Length"] = length
values["content-length"] = length
case "@authority", "@path", "@query":
value, err := derived(extra, request.TargetURI)
if err != nil {
return SignedRequest{}, err
}
values[extra] = value
default:
return SignedRequest{}, fmt.Errorf("component %s is not accepted by FCM", extra)
}
components = append(components, extra)
}
created := request.Created
if created == 0 {
created = time.Now().Unix()
}
nonce := request.Nonce
if nonce == "" {
generated, err := NewNonce()
if err != nil {
return SignedRequest{}, err
}
nonce = generated
}
quoted := make([]string, len(components))
for i, component := range components {
quoted[i] = quote(component)
}
params := fmt.Sprintf("(%s);created=%d;nonce=%s;keyid=%s;alg=\"ed25519\"", strings.Join(quoted, " "), created, quote(nonce), quote(request.KeyID))
lines := make([]string, 0, len(components)+1)
for _, component := range components {
lines = append(lines, fmt.Sprintf("%s: %s", quote(component), values[component]))
}
lines = append(lines, fmt.Sprintf("%s: %s", quote("@signature-params"), params))
signatureBase := strings.Join(lines, "\n")
signature, err := request.PrivateKey.Sign(nil, []byte(signatureBase), crypto.Hash(0))
if err != nil {
return SignedRequest{}, fmt.Errorf("sign request: %w", err)
}
headers["Signature-Input"] = "sig1=" + params
headers["Signature"] = fmt.Sprintf("sig1=:%s:", b64(signature))
return SignedRequest{Headers: headers, SignatureBase: signatureBase}, nil
}
Verificar webhooks
Verificá FCM-Signature sobre los bytes crudos antes de parsear el JSON, y rechazá un jti ya procesado.
/**
* 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;
}
"""Verificación de un webhook de FCM (JWS compacto) ANTES de parsear el cuerpo.
Requiere ``cryptography``. El JWKS se pide por HTTPS y se cachea; ante un ``kid`` desconocido, refrescá como máximo
una vez por minuto. Pasos: clave por ``kid`` con el ``alg`` del JWKS, firma, ``iat``/``exp`` (5 minutos con 30 s de
margen), ``body_sha256`` sobre los bytes crudos, ``aud`` igual a tu ``entity_id`` y ``jti`` no visto (anti-replay,
OBLIGATORIO). Los claims desconocidos se ignoran: FCM puede sumar claims sin aviso de versión.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import threading
import time
from dataclasses import dataclass
from typing import Any, Iterable, Protocol
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
CLOCK_SKEW_SECONDS = 30
MAX_AGE_SECONDS = 300
REPLAY_TTL_SECONDS = 600
def _b64u_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def _b64u(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
@dataclass(frozen=True)
class Jwk:
kty: str
crv: str
x: str
kid: str
alg: str
use: str
y: str | None = None
@dataclass(frozen=True)
class WebhookClaims:
iss: str
aud: str
iat: int
exp: int
jti: str
evt: str
body_sha256: str
class ReplayGuard(Protocol):
"""Anti-replay del ``jti``: 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.
"""
def check_and_store(self, jti: str, now: int) -> bool: ...
class InMemoryReplayGuard:
"""Guarda de un solo proceso, segura con hilos. Sólo para empezar: se pierde al reiniciar."""
def __init__(self) -> None:
self._seen: dict[str, int] = {}
self._lock = threading.Lock()
def check_and_store(self, jti: str, now: int) -> bool:
with self._lock:
for seen_jti, seen_expires in list(self._seen.items()):
if seen_expires <= now:
del self._seen[seen_jti]
if jti in self._seen:
return False
self._seen[jti] = now + REPLAY_TTL_SECONDS
return True
def _segment(value: str) -> dict[str, Any]:
parsed = json.loads(_b64u_decode(value))
if not isinstance(parsed, dict):
raise ValueError("malformed JWS segment")
return parsed
def _text(claims: dict[str, Any], name: str) -> str:
value = claims.get(name)
if not isinstance(value, str) or not value:
raise ValueError(f"missing claim {name}")
return value
def _integer(claims: dict[str, Any], name: str) -> int:
value = claims.get(name)
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"invalid claim {name}")
return value
def _known_claims(claims: dict[str, Any]) -> WebhookClaims:
# Sólo los claims conocidos: uno nuevo (cambio aditivo) no rompe la verificación.
return WebhookClaims(
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"),
)
def _public_key(jwk: Jwk):
x = _b64u_decode(jwk.x)
if jwk.alg == "EdDSA" and (jwk.kty, jwk.crv) == ("OKP", "Ed25519"):
return ed25519.Ed25519PublicKey.from_public_bytes(x)
if jwk.alg == "ES256" and (jwk.kty, jwk.crv) == ("EC", "P-256") and jwk.y is not None:
numbers = ec.EllipticCurvePublicNumbers(int.from_bytes(x, "big"), int.from_bytes(_b64u_decode(jwk.y), "big"), ec.SECP256R1())
return numbers.public_key()
raise ValueError("unsupported or malformed webhook JWK")
def verify_fcm_webhook(
*,
token: str,
raw_body: bytes,
jwks: Iterable[Jwk],
entity_id: str,
replay_guard: ReplayGuard,
issuer: str = "https://api.fcmpayments.com",
event_id: str | None = None,
delivery_id: str | None = None,
now: int | None = None,
) -> WebhookClaims:
parts = token.split(".")
if len(parts) != 3 or any(not part for part in parts):
raise ValueError("malformed JWS")
header = _segment(parts[0])
alg = header.get("alg")
if alg not in {"EdDSA", "ES256"}:
raise ValueError("unsupported webhook algorithm")
key = next((candidate for candidate in jwks if candidate.kid == header.get("kid")), None)
if key is None or key.alg != alg or key.use != "sig":
raise ValueError("unknown or mismatched webhook key")
signature = _b64u_decode(parts[2])
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
try:
public = _public_key(key)
if alg == "EdDSA":
public.verify(signature, signing_input)
else:
if len(signature) != 64:
raise ValueError("ES256 JWS signature must be 64 bytes")
der = encode_dss_signature(int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big"))
public.verify(der, signing_input, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as exc:
raise ValueError("invalid webhook signature") from exc
claims = _known_claims(_segment(parts[1]))
current = int(time.time()) if now is None else now
if (
claims.iat > current + CLOCK_SKEW_SECONDS
or claims.iat < current - MAX_AGE_SECONDS - CLOCK_SKEW_SECONDS
or claims.exp <= current - CLOCK_SKEW_SECONDS
or claims.exp - claims.iat != MAX_AGE_SECONDS
):
raise ValueError("expired or not-yet-valid webhook")
if not hmac.compare_digest(_b64u(hashlib.sha256(raw_body).digest()), claims.body_sha256):
raise ValueError("webhook body hash mismatch")
if claims.iss != issuer or claims.aud != entity_id:
raise ValueError("invalid webhook issuer or audience")
if event_id is not None and claims.evt != event_id:
raise ValueError("webhook event id mismatch")
if delivery_id is not None and claims.jti != delivery_id:
raise ValueError("webhook delivery id mismatch")
# Último: un token inválido nunca consume el jti de una entrega legítima.
if not replay_guard.check_and_store(claims.jti, current):
raise ValueError("webhook replay")
return claims
// Package integradores verifica webhooks de FCM (JWS compacto) y firma pedidos (RFC 9421).
//
// Verificá ANTES de parsear el cuerpo: clave por kid con el alg del JWKS, firma, iat/exp (5 minutos con 30 s de
// margen), body_sha256 sobre los bytes crudos, aud igual a tu entity_id y jti no visto (anti-replay, OBLIGATORIO).
// Los claims desconocidos se ignoran: FCM puede sumar claims sin aviso de versión.
package integradores
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"strings"
"sync"
"time"
)
const (
clockSkewSeconds = 30
maxAgeSeconds = 300
replayTTL = 10 * time.Minute
)
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y,omitempty"`
Kid string `json:"kid"`
Alg string `json:"alg"`
Use string `json:"use"`
}
type WebhookClaims struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Event string `json:"evt"`
BodySHA256 string `json:"body_sha256"`
}
// ReplayGuard es el anti-replay del jti: 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, y guardá el jti en la misma transacción que el
// efecto de negocio.
type ReplayGuard interface {
CheckAndStore(jti string, now time.Time) bool
}
// MemoryReplayGuard es seguro con varias goroutines (un servidor net/http atiende en paralelo). Sólo para empezar:
// se pierde al reiniciar.
type MemoryReplayGuard struct {
mu sync.Mutex
seen map[string]time.Time
}
func NewMemoryReplayGuard() *MemoryReplayGuard {
return &MemoryReplayGuard{seen: map[string]time.Time{}}
}
func (g *MemoryReplayGuard) CheckAndStore(jti string, now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
for id, expires := range g.seen {
if !expires.After(now) {
delete(g.seen, id)
}
}
if _, exists := g.seen[jti]; exists {
return false
}
g.seen[jti] = now.Add(replayTTL)
return true
}
// VerifyOptions agrupa lo que hace falta para verificar. ReplayGuard es obligatorio.
type VerifyOptions struct {
Token string
RawBody []byte
JWKS []JWK
EntityID string
ReplayGuard ReplayGuard
Issuer string // por defecto https://api.fcmpayments.com
EventID string // FCM-Event-Id, si lo querés atar al token
DeliveryID string // FCM-Delivery-Id, si lo querés atar al token
Now time.Time
}
func decodeURL(value string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(value)
}
func publicKey(jwk JWK) (any, error) {
x, err := decodeURL(jwk.X)
if err != nil {
return nil, fmt.Errorf("decode JWK x: %w", err)
}
if jwk.Alg == "EdDSA" && jwk.Kty == "OKP" && jwk.Crv == "Ed25519" {
if len(x) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid Ed25519 key length")
}
return ed25519.PublicKey(x), nil
}
if jwk.Alg == "ES256" && jwk.Kty == "EC" && jwk.Crv == "P-256" {
y, err := decodeURL(jwk.Y)
if err != nil {
return nil, fmt.Errorf("decode JWK y: %w", err)
}
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil
}
return nil, fmt.Errorf("unsupported or malformed webhook JWK")
}
// VerifyFcmWebhook verifica el token y el cuerpo crudo. Parseá el JSON sólo si devuelve nil.
func VerifyFcmWebhook(options VerifyOptions) (WebhookClaims, error) {
if options.ReplayGuard == nil {
return WebhookClaims{}, fmt.Errorf("a ReplayGuard is required")
}
parts := strings.Split(options.Token, ".")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return WebhookClaims{}, fmt.Errorf("malformed JWS")
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
headerBytes, err := decodeURL(parts[0])
if err != nil || json.Unmarshal(headerBytes, &header) != nil {
return WebhookClaims{}, fmt.Errorf("malformed JWS header")
}
if header.Alg != "EdDSA" && header.Alg != "ES256" {
return WebhookClaims{}, fmt.Errorf("unsupported webhook algorithm")
}
var jwk *JWK
for index := range options.JWKS {
if options.JWKS[index].Kid == header.Kid {
jwk = &options.JWKS[index]
break
}
}
if jwk == nil || jwk.Alg != header.Alg || jwk.Use != "sig" {
return WebhookClaims{}, fmt.Errorf("unknown or mismatched webhook key")
}
signature, err := decodeURL(parts[2])
if err != nil {
return WebhookClaims{}, fmt.Errorf("malformed JWS signature")
}
key, err := publicKey(*jwk)
if err != nil {
return WebhookClaims{}, err
}
signed := []byte(parts[0] + "." + parts[1])
valid := false
if header.Alg == "EdDSA" {
edKey, ok := key.(ed25519.PublicKey)
valid = ok && len(signature) == ed25519.SignatureSize && ed25519.Verify(edKey, signed, signature)
} else {
ecKey, ok := key.(*ecdsa.PublicKey)
if !ok || len(signature) != 64 {
return WebhookClaims{}, fmt.Errorf("ES256 JWS signature must be 64 bytes")
}
digest := sha256.Sum256(signed)
valid = ecdsa.Verify(ecKey, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:]))
}
if !valid {
return WebhookClaims{}, fmt.Errorf("invalid webhook signature")
}
// json.Unmarshal ignora claims desconocidos: un claim nuevo no rompe la verificación.
var claims WebhookClaims
claimBytes, err := decodeURL(parts[1])
if err != nil || json.Unmarshal(claimBytes, &claims) != nil || claims.JTI == "" || claims.Event == "" {
return WebhookClaims{}, fmt.Errorf("malformed JWS claims")
}
now := options.Now
if now.IsZero() {
now = time.Now()
}
nowUnix := now.Unix()
if claims.Iat > nowUnix+clockSkewSeconds || claims.Iat < nowUnix-maxAgeSeconds-clockSkewSeconds || claims.Exp <= nowUnix-clockSkewSeconds || claims.Exp-claims.Iat != maxAgeSeconds {
return WebhookClaims{}, fmt.Errorf("expired or not-yet-valid webhook")
}
digest := sha256.Sum256(options.RawBody)
if subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(digest[:])), []byte(claims.BodySHA256)) != 1 {
return WebhookClaims{}, fmt.Errorf("webhook body hash mismatch")
}
issuer := options.Issuer
if issuer == "" {
issuer = "https://api.fcmpayments.com"
}
if claims.Iss != issuer || claims.Aud != options.EntityID {
return WebhookClaims{}, fmt.Errorf("invalid webhook issuer or audience")
}
if options.EventID != "" && claims.Event != options.EventID {
return WebhookClaims{}, fmt.Errorf("webhook event id mismatch")
}
if options.DeliveryID != "" && claims.JTI != options.DeliveryID {
return WebhookClaims{}, fmt.Errorf("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) {
return WebhookClaims{}, fmt.Errorf("webhook replay")
}
return claims, nil
}
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.sequencees contiguo por entidad (1, 2, 3…): si recibiste el 41 y después el 43, te falta el 42. Cada evento de plata traeledger_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
iddel evento y ordená porsequence. - Reintentos: si tu endpoint no responde
2xxen 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/eventsdurante 90 días. - Firma asimétrica: el header
FCM-Signaturees un JWS compacto (JWT) firmado por Grip Rails con una clave asimétrica guardada en KMS (EdDSAsobre Ed25519 oES256sobre P-256; el JWKS dice cuál). Claims:iss(https://api.fcmpayments.com),aud(tuentity_id),iat,exp(iat+ 300),jti(id de entrega, único),evt(id del evento) ybody_sha256(SHA-256 de los bytes crudos del cuerpo, en base64url sin relleno). - Cómo verificar: (1) leé
kidyalgdel encabezado del JWS y buscá esa clave en el JWKS (cacheado; si elkidno está, refrescá como máximo una vez por minuto); (2) verificá la firma con elalgque publica el JWKS para esekid, nunca con el que dice el pedido si no coincide; (3) exigíiatdentro de los últimos 5 minutos yexpfuturo; (4) recalculábody_sha256sobre el cuerpo crudo; (5) rechazá unjtiya visto (guardalo 10 minutos); (6) exigíaudigual a tuentity_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)
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-ControlstringSe puede cachear 5 minutos.
Esquema: Jwks
- ítems mín.
1
Una clave P-256 vigente
{
"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.
Tipos de evento
transfer.incoming.posted— Entró plata a una cuenta de la entidadtransfer.outgoing.reserved— Una transferencia saliente quedó autorizada y reservadatransfer.outgoing.completed— Una transferencia saliente se acreditótransfer.outgoing.failed— Una transferencia saliente fallótransfer.outgoing.reopened— Una transferencia saliente fallida se reabrió
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 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;
});