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 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
}
Verifying webhooks
Verify FCM-Signature over the raw bytes before parsing the JSON, and reject a jti you have already processed.
/**
* 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
}
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.sequenceis contiguous per entity (1, 2, 3…): if you received 41 and then 43, you are missing 42. Every money event carriesledger_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
idand sort bysequence. - Retries: if your endpoint does not respond
2xxwithin 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/eventsfor 90 days. - Asymmetric signature: the
FCM-Signatureheader is a compact JWS (JWT) signed by Grip Rails with an asymmetric key kept in KMS (EdDSAover Ed25519 orES256over P-256; the JWKS says which). Claims:iss(https://api.fcmpayments.com),aud(yourentity_id),iat,exp(iat+ 300),jti(delivery id, unique),evt(event id) andbody_sha256(SHA-256 of the raw body bytes, base64url without padding). - How to verify: (1) read
kidandalgfrom the JWS header and look up that key in the JWKS (cached; if thekidis not there, refresh at most once per minute); (2) verify the signature with thealgthe JWKS publishes for thatkid, never with the one the request states if they differ; (3) requireiatwithin the last 5 minutes and anexpin the future; (4) recomputebody_sha256over the raw body; (5) reject ajtiyou have already seen (keep it for 10 minutes); (6) requireaudto equal yourentity_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)
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-ControlstringCan be cached for 5 minutes.
Schema: Jwks
- min. items
1
A current P-256 key
{
"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.
Event types
transfer.incoming.posted— Money came into one of the entity's accountstransfer.outgoing.reserved— An outgoing transfer was authorized and put on holdtransfer.outgoing.completed— An outgoing transfer was creditedtransfer.outgoing.failed— An outgoing transfer failedtransfer.outgoing.reopened— A failed outgoing transfer was reopened
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 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;
});