Integración con la API de clientes empresa
Esta guía acompaña al contrato openapi.yaml. Los
ejemplos están escritos para poder copiarse a un cliente real, pero siempre
deben probarse primero contra el sandbox.
Para empezar por el sandbox: inicio rápido
(English) y el ejemplo de punta a punta
ejemplo-sandbox.ts.
1. Elegí el entorno
| Producción | Sandbox | |
|---|---|---|
| Base URL | https://api.fcmpayments.com |
https://empresas-sandbox.fcmpayments.com |
| API key | fcm_live_… |
fcm_test_… |
| Dinero | Real; COELSA es irreversible | Simulado; libro y riel separados |
| Simulaciones | No disponibles (404) | /v1/simulations/* |
Una clave de un entorno nunca sirve en el otro.
2. Inicio rápido
Antes de hacer el primer pedido, Grip Rails debe haber dado de alta:
- el
entity_idde la empresa; - una API key para el entorno elegido; y
- la clave pública de firma de pedidos (
ed25519oecdsa-p256-sha256), con sukeyid(ck_…).
La clave privada queda siempre en el sistema de la empresa. No se manda a Grip Rails, no se guarda en variables de entorno compartidas y no se incluye en logs.
Para un POST:
- Serializá el cuerpo una sola vez y conservá esos bytes exactos.
- Calculá
Content-Digest: sha-256=:<base64>:sobre esos bytes. - Generá un
Idempotency-Keynuevo para cada intención. Si se pierde la respuesta, repetí el mismo pedido con la misma clave. - Construí la firma HTTP RFC 9421 cubriendo
@methody@target-uri; con cuerpo agregácontent-type,content-digesteidempotency-key. Si enviásFCM-Version, cubrí tambiénfcm-version. No cubrasauthorization: la API key va en el pedido pero no en la firma (Grip Rails respondesignature_invalid). Así la firma de cada orden se puede volver a verificar con tu clave pública sin conocer tu API key. - Usá un
noncealeatorio nuevo en cada pedido (16 bytes en base64url, por ejemplo) ycreatedcon la hora actual: Grip Rails rechaza unnoncerepetido (signature_replayed) y una firma de más de 5 minutos. - Enviá
Authorization,Signature-Input,Signature,Content-DigestyIdempotency-Keyjunto con el cuerpo.
Componentes extra. Además de los obligatorios, el borde acepta que la firma
cubra date, content-length, @authority, @path y @query (lista
cerrada; cualquier otro componente se rechaza). @authority es el host en
minúsculas, @path la ruta (/ si está vacía) y @query la query con su ?
(? sola si no hay). Como parámetros de la firma se aceptan created,
expires, nonce, keyid, alg y tag, con cualquier etiqueta de firma.
Los fragmentos completos están en firma-pedidos.ts,
firma-pedidos.py y
firma-pedidos.go: los tres generan el nonce solos y
sólo aceptan uno fijo para reproducir un vector de prueba. Todos implementan
Ed25519; el adaptador de clave P-256 usa la misma base de firma y declara
alg="ecdsa-p256-sha256".
# Un pedido GET no lleva Idempotency-Key ni Content-Digest.
curl --fail-with-body \
-H 'Authorization: Bearer fcm_test_…' \
-H 'Signature-Input: …' \
-H 'Signature: …' \
'https://empresas-sandbox.fcmpayments.com/v1/events?after_sequence=0'
Todas las escrituras (POST) requieren Idempotency-Key. La misma clave con
el mismo método, ruta y cuerpo devuelve la respuesta original y
Idempotent-Replayed: true; con otro cuerpo devuelve 409 idempotency_key_reused.
3. Transferencias salientes: 201, 202 y 422
POST /v1/outgoing_transfers responde:
-
201con la transferenciareserved(ocompletedsi el destino es una cuenta de Grip Rails): la autorización aprobó y la plata quedó reservada. -
422 transfer_declinedcon la transferenciarejected: la autorización la rechazó y no salió plata. -
202con la transferenciapending:Si Grip Rails no puede confirmar el resultado en el momento:
202con la transferencia enpending, o ensubmittedconstatus_reason: rail_outcome_unknownsi un reintento la encuentra ya enviada y sin resultado. La orden existe y está atada a tu Idempotency-Key: no la repitas con otra clave. Unapendingtermina enreserved(eventotransfer.outgoing.reserved) o enfailedconfailure.code: canceled_by_fcm(eventotransfer.outgoing.failed).Estado Qué significa Reserva ISO 20022 pendingGrip Rails recibió la orden pero todavía no puede confirmar la autorización. Puede haber plata reservada. No reintentes con otra Idempotency-Key. Puede haber una pendiente PDNGUna respuesta
202se guarda y se repite igual que cualquier2xx. El estado vigente se pide conGET /v1/outgoing_transfers/{id}o llega por webhook. Enauthorization.decision,pendingquiere decir que la autorización todavía no terminó (decided_ates la hora en que quedó pendiente).
Créditos internos (fcm_book). Una transferencia a un CVU de otra empresa
cliente de Grip Rails se liquida en el libro de Grip Rails, sin pasar por COELSA. La empresa
que recibe ve un crédito entrante con rail.name: fcm_book; su
rail.operation_id es el otr_… de origen (la clave de idempotencia del
asiento), en lugar del id de COELSA. Llega con su evento
transfer.incoming.posted y su webhook, igual que cualquier crédito.
Acreditación tardía. Si COELSA acredita una transferencia
que ya te informamos failed (su aviso llegó tarde o se perdió), la plata
salió: la transferencia vuelve a submitted con status_reason: late_settlement_review, el importe se debita con un asiento compensatorio y
llega el evento transfer.outgoing.reopened (con sus referencias). Grip Rails
deshabilita tus salidas (siguen deshabilitadas, aun después de completed,
hasta que Grip Rails las vuelva a habilitar) y, cuando dos personas de Grip Rails confirman
la acreditación, la cierra en completed (evento
transfer.outgoing.completed) sin cargo: Grip Rails no cobra comisión por una
transferencia reabierta, porque la reapertura no es culpa tuya. charges lo
dice con un fcm_fee de 0 y su motivo, y total_debit_amount es el importe.
Tratá failed como final salvo ese evento. En el sandbox se puede provocar de
punta a punta (§5); ahí, a diferencia de producción, tus salidas se vuelven a
habilitar solas después del cierre.
4. Eventos y webhooks
El webhook es un aviso delgado. El cuerpo contiene el tipo, la secuencia y la
referencia al objeto; para el estado vigente hacé el GET correspondiente.
Procesalo así:
- Conservá los bytes crudos del body. Verificá
FCM-Signatureantes de parsear JSON. - Leé
kidyalgdel encabezado JWS y buscá exactamente esa clave enGET /.well-known/jwks.json(cacheado; ante unkiddesconocido, refrescá como máximo una vez por minuto). El algoritmo del encabezado debe coincidir con el publicado para esekid. - Validá la firma,
iatdentro de los últimos cinco minutos yexptodavía futuro (hasta 30 s de margen de reloj),iss(https://api.fcmpayments.com) yaudigual a tuentity_id. - Recalculá
body_sha256sobre los bytes crudos (base64url sin=) y comparalo con el claim. Validá tambiénevtcontrabody.idy el encabezadoFCM-Event-Id, yjticontraFCM-Delivery-Id. - Rechazá un
jtique ya procesaste: el anti-replay es obligatorio. Guardá cadajtial menos diez minutos, en un almacén compartido si tenés varias instancias, y persistí la deduplicación junto con el efecto de negocio. Cada intento de entrega tiene su propiojti(un reintento trae otrojtiyFCM-Delivery-Attemptmayor). - Deduplicá además por
event.idy ordená porsequence. Si ves 41 y luego 43, recuperá 42 conGET /v1/events?after_sequence=41. - Respondé
2xxen menos de diez segundos (contando desde que Grip Rails empieza a resolver tu host hasta que termina de leer tu respuesta) y procesá en segundo plano. Un2xxes el único éxito; una redirección es una falla.
Ignorá los claims y los tipos de evento que no conozcas: pueden aparecer sin aviso de versión.
La política es al menos una vez: 25 intentos (el primero y 24 reintentos), con
variación aleatoria de ±20 %: 30s, 1m, 2m, 5m, 10m, 15m, 30m, 1h, 2h, 3h, 4h, 5h y después cada 8h (unos 4,7 días). Los eventos permanecen 90 días en
/v1/events.
Tu URL. La registra Grip Rails en el alta y se cambia por pedido verificado. Tiene
que ser https:// al puerto 443, con TLS 1.2 o superior, sin usuario,
contraseña ni fragmento, y resolver sólo a direcciones públicas: Grip Rails no entrega
a redes privadas ni reservadas, ni a rangos de COELSA o de Grip Rails. Si Grip Rails cambia tu
URL, las entregas pendientes dejan de ir a la vieja; lo que quedó en camino se
reenvía a la nueva a pedido, o lo recuperás con GET /v1/events.
La verificación lista para copiar está en
verificar-webhooks.ts,
verificar-webhooks.py y
verificar-webhooks.go. Las tres soportan EdDSA
Ed25519 y ES256 P-256, exigen una guarda anti-replay (traen una en memoria,
segura con hilos o goroutines, sólo para empezar) y toleran claims nuevos.
Grip Rails las prueba contra los vectores de la suite de conformidad.
5. Sandbox determinista
El sandbox usa cuentas, libro, eventos y webhooks propios, y recorre el mismo camino que producción: un crédito simulado se asienta, cobra comisión, emite su evento y dispara el webhook. Los valores mágicos permiten probar los caminos sin tocar dinero real:
| Entrada | Resultado |
|---|---|
Importe termina en …13 |
failed, destination_closed (COELSA 7502) |
Importe termina en …70 |
failed, rail_expired (COELSA 7570) |
Importe termina en …99 |
Queda submitted hasta inyectar resultado |
| Cualquier otro importe | Se completa aproximadamente en 2 s |
Acreditación tardía, en dos pasos:
- Sobre una transferencia
failedque salió por COELSA (por ejemplo, una…13),POST /v1/simulations/outgoing_transfers/{id}/rail_resultcon{"outcome":"completed"}simula que COELSA la acreditó después: responde200con la transferencia ensubmittedystatus_reason: late_settlement_review, el importe se debita con el asiento compensatorio y llegatransfer.outgoing.reopened. POST /v1/simulations/outgoing_transfers/{id}/late_settlement_reviewcon{"outcome":"completed"}simula que dos personas de Grip Rails confirman la acreditación (en el sandbox, dos operadores sintéticos, por el mismo camino que en producción): responde202con la transferencia todavía reabierta y unos segundos después llegatransfer.outgoing.completed, sin cargo.
Como en producción, después del primer paso tus transferencias salientes quedan
deshabilitadas (403 entity_suspended). Sólo en el sandbox, unos segundos
después del transfer.outgoing.completed del segundo paso Grip Rails las vuelve a
habilitar sola (GET /v1/entity vuelve a active; no si Grip Rails bloqueó tu
entidad en el medio) y podés seguir probando. En producción no: siguen
deshabilitadas hasta que Grip Rails las rehabilite.
Alias para validar contrapartes:
| Alias | Validación | Transferencia |
|---|---|---|
sbx.valida |
valid |
se autoriza |
sbx.inactiva |
inactive |
destination_inactive |
sbx.inexistente |
not_found |
destination_not_found |
sbx.bloqueada |
blocked |
compliance_rejected |
sbx.otro.titular |
holder_mismatch si mandás expected_cuit |
destination_holder_mismatch |
También se aceptan CBU/CVU con dígitos verificadores válidos, con titular sintético.
Un flujo mínimo de sandbox es:
POST /v1/simulations/incoming_transfers
-> crédito asentado, evento transfer.incoming.posted y webhook
POST /v1/simulations/outgoing_transfers/{id}/rail_result
{"outcome":"completed"|"failed", "coelsa_code":"7502"}
(para una transferencia submitted; completed con un código de rechazo es 400 parameter_invalid;
sobre una failed que salió por COELSA, sólo completed: la acreditación tardía)
POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review
{"outcome":"completed"}
(sólo para una reabierta: 202, Grip Rails la cierra en completed sin cargo y, sólo en el sandbox,
después vuelve a habilitar tus transferencias salientes)
GET /v1/simulations/clock
POST /v1/simulations/clock/advance {"advance_seconds":86400}
El reloj avanza sólo hacia adelante y afecta topes diarios/mensuales,
reintentos de webhooks y vencimientos. Los POST de simulación también llevan
firma e Idempotency-Key.
6. Suite de conformidad
Antes de pasar a producción, pedile a Grip Rails la suite de conformidad: casos de prueba en JSON para correr contra tu integración, con claves de ejemplo. Esas claves son públicas: nunca las registres en ningún entorno.
Los errores se entregan como {error:{type,code,message,display_message,param,request_id,retryable,doc_url,transfer_id}}.
Para un error retryable: true, repetí el mismo pedido y la misma
Idempotency-Key; nunca inventes una clave nueva para una transferencia cuya
respuesta se perdió.