RAILS
Sandbox

Inicio rápido: el sandbox de la API de clientes empresa

Para integrar contra Grip Rails sin plata real: ingreso de ARS (créditos a tus CVU) y egreso de ARS (transferencias a CBU, CVU o alias). El sandbox recorre el mismo camino que producción (autorización, reserva en el libro, comisiones, eventos y webhooks), pero con un riel de COELSA simulado, otro libro y otra base. Esta guía es el camino corto; el detalle está en la guía de integración y el contrato en openapi.yaml. English version: Quickstart (English).

0. Lo que te damos en el alta (y lo que nos mandás)

Qué es
URL base La que te pasamos en el alta; hoy https://empresas-sandbox.fcmpayments.com. Guardala en configuración (por ejemplo FCM_BASE_URL), no en el código.
entity_id ent_…, tu entidad (environment: test).
Cuenta de fondos acc_…: de acá salen los egresos y acá se acreditan los ingresos.
Números de cuenta Uno o más acn_…, cada uno con su CVU (sintético en el sandbox).
API key fcm_test_…. Te llega cifrada con tu clave PGP (o por un enlace de un solo uso). Nunca por chat ni mail en claro.
keyid de firma ck_…: el id de TU clave pública, registrada por Grip Rails.

Lo que nos mandás antes: sólo la clave pública de firma de pedidos (Ed25519 o P-256, PEM), tu clave PGP pública para recibir la API key y, si querés webhooks, tu URL (https al 443, TLS 1.2 o más, sin redirecciones, responde en menos de 10 s). La clave privada nunca sale de tu sistema:

BASH
openssl genpkey -algorithm ed25519 -out fcm-firma-privada.pem
openssl pkey -in fcm-firma-privada.pem -pubout -out fcm-firma-publica.pem   # ésta es la que nos mandás

1. Autenticación: API key y firma de cada pedido

Todo pedido lleva las dos cosas:

  1. Authorization: Bearer fcm_test_….
  2. Firma HTTP (RFC 9421) hecha con tu clave privada: componentes @method y @target-uri; con cuerpo, además content-type, content-digest (RFC 9530, sobre los bytes exactos que mandás) e idempotency-key. Parámetros: created (segundos Unix, ±300 s), nonce aleatorio nuevo en cada pedido, keyid (ck_…) y alg. La API key no se firma: una firma que cubre authorization se rechaza (signature_invalid).

@target-uri es la URL base del alta + la ruta + la query, tal cual va por el cable. Los firmantes listos para copiar están en firma-pedidos.ts, .py y .go.

Todo POST lleva Idempotency-Key (16 a 128 caracteres A-Z a-z 0-9 . _ : -; un UUID sirve). Límites por entidad: 5 escrituras y 25 lecturas por segundo; al pasarte, 429 con Retry-After. Los errores típicos al empezar: signature_expired (reloj), signature_replayed (repetiste un nonce), content_digest_mismatch (mandaste otros bytes que los firmados) y environment_mismatch (una key fcm_live_ en el sandbox).

Ejemplo de punta a punta: ejemplo-sandbox.ts hace todo lo de esta guía (Node ≥ 22, sin dependencias). Con firma-pedidos.ts al lado:

BASH
FCM_BASE_URL=https://empresas-sandbox.fcmpayments.com FCM_API_KEY_FILE=./api-key.txt FCM_KEY_ID=ck_… \
FCM_PRIVATE_KEY_FILE=./fcm-firma-privada.pem npx tsx ejemplo-sandbox.ts

2. Valores mágicos del riel simulado

Importe de un egreso (en centavos; manda la terminación):

Termina en Resultado
…13 failed, failure.code: destination_closed (COELSA 7502)
…70 failed, failure.code: rail_expired (COELSA 7570)
…99 queda submitted hasta que lo resuelvas con rail_result (§4)
cualquier otra completed a los ~2 s

Destinos para validar y transferir:

Destino Validación Transferencia
alias sbx.valida valid (titular CUIT 27000000006) se autoriza
alias sbx.inactiva inactive 422 destination_inactive
alias sbx.inexistente not_found 422 destination_not_found
alias sbx.bloqueada blocked 422 compliance_rejected
alias sbx.otro.titular holder_mismatch si mandás expected_cuit 422 destination_holder_mismatch
cualquier CBU o CVU con dígitos verificadores válidos valid, titular sintético se autoriza

La CUIT 30000000007 siempre da coincidencia en el control de listas. Las comisiones y los topes del sandbox son de ejemplo (los ves en GET /v1/entity): salida $ 5 fijos, entrada 1 %; topes de $ 1.000.000 por transferencia, $ 5.000.000 por día y $ 50.000.000 por mes.

3. Ingreso de ARS

Simular un crédito (lo que en producción es el aviso de COELSA de una transferencia a tu CVU):

HTTP
POST /v1/simulations/incoming_transfers
Idempotency-Key: 2f6b1c0e-…
{"account_number_id":"acn_…","amount":1500000,"payer":{"name":"PAGADOR DE PRUEBA"},"concept":"VAR"}

Responde 201 con el crédito posted: amount, net_amount (neto de la comisión de entrada), pagador, CVU y ledger_references. Te enterás de dos maneras, y se pueden usar las dos:

  • Webhook transfer.incoming.posted, firmado con JWS (verificalo con verificar-webhooks.ts, .py o .go y el JWKS de GET /.well-known/jwks.json). Es delgado: trae el id, no el importe.
  • Consulta periódica de GET /v1/events (sin webhooks, o para conciliar lo que el webhook avisó).

Algoritmo de consulta periódica (cada 2–5 s; una llamada por consulta y una por crédito, muy por debajo de los límites):

  1. Guardá en tu base el último sequence procesado (al empezar, 0).
  2. Pedí GET /v1/events?after_sequence=<último>&limit=100 sin filtro de tipo: así la secuencia es contigua por entidad (1, 2, 3…). Con type= hay saltos por diseño y no podés detectar huecos.
  3. Procesá cada evento en orden, idempotente por su evt_… (y por el itr_…/otr_… del objeto):
    • si sequence no es el último + 1, hay un hueco: es un error de Grip Rails, avisanos y no avances el cursor;
    • transfer.incoming.posted → GET /v1/incoming_transfers/{data.id} para importe, comisión, pagador y CVU;
    • un tipo que no conocés se ignora (pueden aparecer tipos nuevos sin cambio de versión).
  4. Guardá el sequence nuevo en la misma transacción que el efecto de negocio.
  5. Si has_more es true, pedí enseguida desde next_after_sequence; si no, esperá a la próxima vuelta.

Los eventos quedan 90 días. La alternativa GET /v1/incoming_transfers?account_number_id=…&created_after=… también sirve, pero viene del más nuevo al más viejo y no detecta huecos.

4. Egreso de ARS

  1. Validá el destino (recomendado): POST /v1/counterparty_validations con {"destination":{"type":"alias","value":"sbx.valida"},"expected_cuit":"27000000006"} → 201, result: valid y un cval_… que vale 15 minutos.

  2. Ordená con una Idempotency-Key NUEVA por intención de pago, guardada en tu base antes de mandar el pedido:

    HTTP
    POST /v1/outgoing_transfers
    Idempotency-Key: 7c1d9a54-…
    {"account_id":"acc_…","amount":100000,"currency":"ARS","destination":{"type":"alias","value":"sbx.valida"},
     "expected_counterparty_cuit":"27000000006","counterparty_validation_id":"cval_…","external_reference":"pago-123"}
    
    Respuesta Qué significa Qué hacés
    201 reserved Autorizada: la plata (importe + comisión, total_debit_amount) quedó reservada. completed directo si el destino es otra cuenta de Grip Rails. Esperás completed o failed (webhook, /v1/events o GET /v1/outgoing_transfers/{id}).
    202 pending Grip Rails no pudo confirmar la autorización en el momento. Puede haber plata reservada. Igual que arriba. Nunca la repitas con otra clave. Termina en reserved o en failed (canceled_by_fcm).
    202 submitted + status_reason: rail_outcome_unknown Un reintento la encontró enviada y sin resultado. Esperás; Grip Rails lo resuelve con COELSA.
    422 transfer_declined La autorización la rechazó (insufficient_funds, limit_*, destination_*, compliance_rejected…). No salió plata. El error.code dice por qué; el transfer_id queda en estado rejected.
    5xx o 409 idempotency_request_in_progress Reintentable (retryable: true). Repetís el mismo pedido con la misma clave.
  3. Si se pierde la respuesta, repetí el mismo pedido con la misma clave: vuelve la respuesta guardada (Idempotent-Replayed: true) o el estado actual. La misma clave con otro cuerpo es 409 idempotency_key_reused. El estado vigente también se pide con GET /v1/outgoing_transfers?idempotency_key=… o GET /v1/idempotency_keys/{key}. Poné un timeout de cliente de 30 s o más en este POST: el borde espera hasta 20 s a la autorización.

  4. Estados: pending → reserved → submitted → completed o failed (rejected sólo llega como respuesta 422, sin evento). Eventos: transfer.outgoing.reserved, transfer.outgoing.completed, transfer.outgoing.failed. Si COELSA acredita una failed después (acreditación tardía), vuelve a submitted con status_reason: late_settlement_review (evento transfer.outgoing.reopened, con el importe ya debitado) y Grip Rails la cierra en completed, sin cargo (charges trae un fcm_fee de 0 con su motivo). Tratá failed como final salvo ese evento. En el sandbox lo podés provocar (paso 6).

  5. Resolver un …99 a mano: POST /v1/simulations/outgoing_transfers/{id}/rail_result con {"outcome":"completed"} o {"outcome":"failed","coelsa_code":"7502"} y su Idempotency-Key → 200 con la transferencia. Sólo para una submitted; completed con un código de rechazo es 400 parameter_invalid.

  6. Simular una acreditación tardía: sobre una failed que salió por COELSA (por ejemplo, una …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result con {"outcome":"completed"} → 200 con la transferencia en submitted + status_reason: late_settlement_review, el importe debitado y el evento transfer.outgoing.reopened. Después, POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review con {"outcome":"completed"} (dos personas de Grip Rails confirman; en el sandbox, dos operadores sintéticos) → 202 con la transferencia todavía reabierta, y a los pocos segundos transfer.outgoing.completed, sin cargo. Como en producción, desde el primer paso tus salidas quedan deshabilitadas (403 entity_suspended). Sólo en el sandbox, unos segundos después de ese completed Grip Rails las vuelve a habilitar sola (GET /v1/entity vuelve a active; no si Grip Rails bloqueó tu entidad en el medio); en producción siguen deshabilitadas hasta que Grip Rails las rehabilite.

5. Reloj de simulación

Cada entidad del sandbox tiene su reloj: GET /v1/simulations/clock → {"now":"…","offset_seconds":0}, y POST /v1/simulations/clock/advance con {"advance_seconds":86400} (hasta 31 días por pedido, con Idempotency-Key) lo adelanta, nunca lo atrasa. Mueve el día y el mes de negocio de Buenos Aires (topes diarios y mensuales), los reintentos de webhooks pendientes y los vencimientos. Por ejemplo: llegá al tope diario (422 limit_daily_exceeded), adelantá un día y la misma orden con otra clave vuelve a pasar.

6. Antes de producción

En producción cambian la URL base (https://api.fcmpayments.com), la key (fcm_live_…, otra, con tu clave pública de producción) y la plata, que es real: COELSA es irreversible. /v1/simulations/* no existe (404) y los webhooks se firman con una clave de KMS que se publica en el JWKS 7 días antes de usarse. Lo demás es igual: si tu integración pasa el ejemplo de punta a punta en el sandbox, el contrato es el mismo.