RAILS
Sandbox

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:

  1. el entity_id de la empresa;
  2. una API key para el entorno elegido; y
  3. la clave pública de firma de pedidos (ed25519 o ecdsa-p256-sha256), con su keyid (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:

  1. Serializá el cuerpo una sola vez y conservá esos bytes exactos.
  2. Calculá Content-Digest: sha-256=:<base64>: sobre esos bytes.
  3. Generá un Idempotency-Key nuevo para cada intención. Si se pierde la respuesta, repetí el mismo pedido con la misma clave.
  4. Construí la firma HTTP RFC 9421 cubriendo @method y @target-uri; con cuerpo agregá content-type, content-digest e idempotency-key. Si enviás FCM-Version, cubrí también fcm-version. No cubras authorization: la API key va en el pedido pero no en la firma (Grip Rails responde signature_invalid). Así la firma de cada orden se puede volver a verificar con tu clave pública sin conocer tu API key.
  5. Usá un nonce aleatorio nuevo en cada pedido (16 bytes en base64url, por ejemplo) y created con la hora actual: Grip Rails rechaza un nonce repetido (signature_replayed) y una firma de más de 5 minutos.
  6. Enviá Authorization, Signature-Input, Signature, Content-Digest y Idempotency-Key junto 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".

BASH
# 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:

  • 201 con la transferencia reserved (o completed si el destino es una cuenta de Grip Rails): la autorización aprobó y la plata quedó reservada.

  • 422 transfer_declined con la transferencia rejected: la autorización la rechazó y no salió plata.

  • 202 con la transferencia pending:

    Si Grip Rails no puede confirmar el resultado en el momento: 202 con la transferencia en pending, o en submitted con status_reason: rail_outcome_unknown si 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. Una pending termina en reserved (evento transfer.outgoing.reserved) o en failed con failure.code: canceled_by_fcm (evento transfer.outgoing.failed).

    Estado Qué significa Reserva ISO 20022
    pending Grip 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 PDNG

    Una respuesta 202 se guarda y se repite igual que cualquier 2xx. El estado vigente se pide con GET /v1/outgoing_transfers/{id} o llega por webhook. En authorization.decision, pending quiere decir que la autorización todavía no terminó (decided_at es 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í:

  1. Conservá los bytes crudos del body. Verificá FCM-Signature antes de parsear JSON.
  2. Leé kid y alg del encabezado JWS y buscá exactamente esa clave en GET /.well-known/jwks.json (cacheado; ante un kid desconocido, refrescá como máximo una vez por minuto). El algoritmo del encabezado debe coincidir con el publicado para ese kid.
  3. Validá la firma, iat dentro de los últimos cinco minutos y exp todavía futuro (hasta 30 s de margen de reloj), iss (https://api.fcmpayments.com) y aud igual a tu entity_id.
  4. Recalculá body_sha256 sobre los bytes crudos (base64url sin =) y comparalo con el claim. Validá también evt contra body.id y el encabezado FCM-Event-Id, y jti contra FCM-Delivery-Id.
  5. Rechazá un jti que ya procesaste: el anti-replay es obligatorio. Guardá cada jti al 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 propio jti (un reintento trae otro jti y FCM-Delivery-Attempt mayor).
  6. Deduplicá además por event.id y ordená por sequence. Si ves 41 y luego 43, recuperá 42 con GET /v1/events?after_sequence=41.
  7. Respondé 2xx en 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. Un 2xx es 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:

  1. Sobre una transferencia failed que salió por COELSA (por ejemplo, una …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result con {"outcome":"completed"} simula que COELSA la acreditó después: responde 200 con la transferencia en submitted y status_reason: late_settlement_review, el importe se debita con el asiento compensatorio y llega transfer.outgoing.reopened.
  2. POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review con {"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): responde 202 con la transferencia todavía reabierta y unos segundos después llega transfer.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ó.