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:
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:
Authorization: Bearer fcm_test_….- Firma HTTP (RFC 9421) hecha con tu clave privada: componentes
@methody@target-uri; con cuerpo, ademáscontent-type,content-digest(RFC 9530, sobre los bytes exactos que mandás) eidempotency-key. Parámetros:created(segundos Unix, ±300 s),noncealeatorio nuevo en cada pedido,keyid(ck_…) yalg. La API key no se firma: una firma que cubreauthorizationse 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:
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):
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 converificar-webhooks.ts,.pyo.goy el JWKS deGET /.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):
- Guardá en tu base el último
sequenceprocesado (al empezar,0). - Pedí
GET /v1/events?after_sequence=<último>&limit=100sin filtro de tipo: así la secuencia es contigua por entidad (1, 2, 3…). Contype=hay saltos por diseño y no podés detectar huecos. - Procesá cada evento en orden, idempotente por su
evt_…(y por elitr_…/otr_…del objeto):- si
sequenceno 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).
- si
- Guardá el
sequencenuevo en la misma transacción que el efecto de negocio. - Si
has_moreestrue, pedí enseguida desdenext_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
-
Validá el destino (recomendado):
POST /v1/counterparty_validationscon{"destination":{"type":"alias","value":"sbx.valida"},"expected_cuit":"27000000006"}→201,result: validy uncval_…que vale 15 minutos. -
Ordená con una
Idempotency-KeyNUEVA por intención de pago, guardada en tu base antes de mandar el pedido:HTTPPOST /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 201reservedAutorizada: la plata (importe + comisión, total_debit_amount) quedó reservada.completeddirecto si el destino es otra cuenta de Grip Rails.Esperás completedofailed(webhook,/v1/eventsoGET /v1/outgoing_transfers/{id}).202pendingGrip 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 reservedo enfailed(canceled_by_fcm).202submitted+status_reason: rail_outcome_unknownUn reintento la encontró enviada y sin resultado. Esperás; Grip Rails lo resuelve con COELSA. 422 transfer_declinedLa autorización la rechazó ( insufficient_funds,limit_*,destination_*,compliance_rejected…). No salió plata.El error.codedice por qué; eltransfer_idqueda en estadorejected.5xxo409 idempotency_request_in_progressReintentable ( retryable: true).Repetís el mismo pedido con la misma clave. -
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 es409 idempotency_key_reused. El estado vigente también se pide conGET /v1/outgoing_transfers?idempotency_key=…oGET /v1/idempotency_keys/{key}. Poné un timeout de cliente de 30 s o más en estePOST: el borde espera hasta 20 s a la autorización. -
Estados:
pending→reserved→submitted→completedofailed(rejectedsólo llega como respuesta422, sin evento). Eventos:transfer.outgoing.reserved,transfer.outgoing.completed,transfer.outgoing.failed. Si COELSA acredita unafaileddespués (acreditación tardía), vuelve asubmittedconstatus_reason: late_settlement_review(eventotransfer.outgoing.reopened, con el importe ya debitado) y Grip Rails la cierra encompleted, sin cargo (chargestrae unfcm_feede0con su motivo). Tratáfailedcomo final salvo ese evento. En el sandbox lo podés provocar (paso 6). -
Resolver un
…99a mano:POST /v1/simulations/outgoing_transfers/{id}/rail_resultcon{"outcome":"completed"}o{"outcome":"failed","coelsa_code":"7502"}y suIdempotency-Key→200con la transferencia. Sólo para unasubmitted;completedcon un código de rechazo es400 parameter_invalid. -
Simular una acreditación tardía: sobre una
failedque salió por COELSA (por ejemplo, una…13),POST /v1/simulations/outgoing_transfers/{id}/rail_resultcon{"outcome":"completed"}→200con la transferencia ensubmitted+status_reason: late_settlement_review, el importe debitado y el eventotransfer.outgoing.reopened. Después,POST /v1/simulations/outgoing_transfers/{id}/late_settlement_reviewcon{"outcome":"completed"}(dos personas de Grip Rails confirman; en el sandbox, dos operadores sintéticos) →202con la transferencia todavía reabierta, y a los pocos segundostransfer.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 esecompletedGrip Rails las vuelve a habilitar sola (GET /v1/entityvuelve aactive; 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.