RAILS
Sandbox

Integrating with the business clients API

This guide accompanies the API contract. The examples are written so they can be copied into a real client, but they should always be tested against the sandbox first.

To start with the sandbox: the quickstart and the end-to-end example ejemplo-sandbox.ts.

1. Choose the environment

Production Sandbox
Base URL https://api.fcmpayments.com https://empresas-sandbox.fcmpayments.com
API key fcm_live_… fcm_test_…
Money Real; COELSA (the Argentine clearing house for immediate transfers) is irreversible Simulated; separate ledger and rail
Simulations Not available (404) /v1/simulations/*

A key from one environment never works in the other.

2. Quickstart

Before making the first request, Grip Rails must have set up:

  1. the company's entity_id;
  2. an API key for the chosen environment; and
  3. the public key for signing requests (ed25519 or ecdsa-p256-sha256), with its keyid (ck_…).

The private key always stays in the company's system. It is not sent to Grip Rails, it is not stored in shared environment variables and it is not included in logs.

For a POST:

  1. Serialize the body only once and keep those exact bytes.
  2. Compute Content-Digest: sha-256=:<base64>: over those bytes.
  3. Generate a new Idempotency-Key for each intent. If the response is lost, repeat the same request with the same key.
  4. Build the RFC 9421 HTTP signature covering @method and @target-uri; with a body, add content-type, content-digest and idempotency-key. If you send FCM-Version, also cover fcm-version. Do not cover authorization: the API key goes in the request but not in the signature (Grip Rails responds signature_invalid). That way the signature of each order can be re-verified with your public key without knowing your API key.
  5. Use a new random nonce on every request (16 bytes in base64url, for example) and created with the current time: Grip Rails rejects a repeated nonce (signature_replayed) and a signature older than 5 minutes.
  6. Send Authorization, Signature-Input, Signature, Content-Digest and Idempotency-Key together with the body.

Extra components. Besides the required ones, the edge accepts signatures that cover date, content-length, @authority, @path and @query (a closed list; any other component is rejected). @authority is the lowercase host, @path the path (/ if empty) and @query the query with its ? (a lone ? if there is none). Accepted signature parameters are created, expires, nonce, keyid, alg and tag, with any signature label.

The complete snippets are in firma-pedidos.ts, firma-pedidos.py and firma-pedidos.go: all three generate the nonce themselves and only accept a fixed one to reproduce a test vector. All of them implement Ed25519; the P-256 key adapter uses the same signature base and declares alg="ecdsa-p256-sha256".

BASH
# A GET request carries no Idempotency-Key or 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'

Every write (POST) requires an Idempotency-Key. The same key with the same method, path and body returns the original response and Idempotent-Replayed: true; with a different body it returns 409 idempotency_key_reused.

3. Outgoing transfers: 201, 202 and 422

POST /v1/outgoing_transfers responds:

  • 201 with the transfer reserved (or completed if the destination is a Grip Rails account): the authorization approved it and the money is on hold.

  • 422 transfer_declined with the transfer rejected: the authorization declined it and no money moved.

  • 202 with the transfer pending:

    If Grip Rails cannot confirm the result right away: 202 with the transfer in pending, or in submitted with status_reason: rail_outcome_unknown if a retry finds it already sent and without a result. The order exists and is bound to your Idempotency-Key: do not repeat it with another key. A pending transfer ends in reserved (event transfer.outgoing.reserved) or in failed with failure.code: canceled_by_fcm (event transfer.outgoing.failed).

    State What it means Hold ISO 20022
    pending Grip Rails received the order but cannot confirm the authorization yet. Money may be on hold. Do not retry with another Idempotency-Key. There may be a pending one PDNG

    A 202 response is stored and replayed like any 2xx. The current state is available at GET /v1/outgoing_transfers/{id} or arrives by webhook. In authorization.decision, pending means that the authorization has not finished yet (decided_at is the time it became pending).

Internal credits (fcm_book). A transfer to a CVU (Argentine virtual account number) of another Grip Rails business client settles in the Grip Rails ledger, without going through COELSA. The receiving company sees an incoming credit with rail.name: fcm_book; its rail.operation_id is the originating otr_… (the idempotency key of the ledger entry) instead of the COELSA id. It arrives with its transfer.incoming.posted event and its webhook, like any other credit.

Late settlement. If COELSA credits a transfer we already reported to you as failed (its notice arrived late or was lost), the money left: the transfer goes back to submitted with status_reason: late_settlement_review, the amount is debited with a compensating entry and the event transfer.outgoing.reopened arrives (with its references). Grip Rails disables your outgoing transfers (they stay disabled, even after completed, until Grip Rails enables them again) and, when two people at Grip Rails confirm the settlement, closes it as completed (event transfer.outgoing.completed) free of charge: Grip Rails charges no fee for a reopened transfer, because the reopening is not your fault. charges shows it with an fcm_fee of 0 and its reason, and total_debit_amount is the amount. Treat failed as final except for that event. In the sandbox you can trigger it end to end (§5); there, unlike in production, your outgoing transfers are re-enabled on their own after the closing.

4. Events and webhooks

The webhook is a thin notice. The body contains the type, the sequence and the reference to the object; for the current state, make the corresponding GET. Process it like this:

  1. Keep the raw bytes of the body. Verify FCM-Signature before parsing JSON.
  2. Read kid and alg from the JWS header and look up exactly that key in GET /.well-known/jwks.json (cached; for an unknown kid, refresh at most once per minute). The algorithm in the header must match the one published for that kid.
  3. Validate the signature, iat within the last five minutes and exp still in the future (up to 30 s of clock skew), iss (https://api.fcmpayments.com) and aud equal to your entity_id.
  4. Recompute body_sha256 over the raw bytes (base64url without =) and compare it with the claim. Also validate evt against body.id and the FCM-Event-Id header, and jti against FCM-Delivery-Id.
  5. Reject a jti you have already processed: anti-replay is mandatory. Keep each jti for at least ten minutes, in a shared store if you run several instances, and persist the deduplication together with the business effect. Each delivery attempt has its own jti (a retry brings another jti and a higher FCM-Delivery-Attempt).
  6. Also deduplicate by event.id and sort by sequence. If you see 41 and then 43, recover 42 with GET /v1/events?after_sequence=41.
  7. Respond 2xx in under ten seconds (counting from when Grip Rails starts resolving your host until it finishes reading your response) and process in the background. A 2xx is the only success; a redirect is a failure.

Ignore claims and event types you do not know: they can appear without a version notice.

The policy is at least once: 25 attempts (the first one and 24 retries), with ±20% random jitter: 30s, 1m, 2m, 5m, 10m, 15m, 30m, 1h, 2h, 3h, 4h, 5h and then every 8h (about 4.7 days). Events remain in /v1/events for 90 days.

Your URL. Grip Rails registers it at onboarding, and it is changed through a verified request. It has to be https:// on port 443, with TLS 1.2 or higher, without a user, password or fragment, and resolve only to public addresses: Grip Rails does not deliver to private or reserved networks, or to COELSA or Grip Rails ranges. If Grip Rails changes your URL, pending deliveries stop going to the old one; whatever was in flight is resent to the new one on request, or you recover it with GET /v1/events.

The ready-to-copy verification is in verificar-webhooks.ts, verificar-webhooks.py and verificar-webhooks.go. All three support EdDSA Ed25519 and ES256 P-256, require an anti-replay guard (they include an in-memory one, safe with threads or goroutines, only for getting started) and tolerate new claims. Grip Rails tests them against the vectors of the conformance suite.

5. Deterministic sandbox

The sandbox uses its own accounts, ledger, events and webhooks, and goes through the same path as production: a simulated credit is posted, charges a fee, emits its event and fires the webhook. The magic values let you test the paths without touching real money:

Input Result
Amount ends in …13 failed, destination_closed (COELSA 7502)
Amount ends in …70 failed, rail_expired (COELSA 7570)
Amount ends in …99 Stays submitted until a result is injected
Any other amount Completes in about 2 s

Late settlement, in two steps:

  1. On a failed transfer that went out through COELSA (for example, a …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result with {"outcome":"completed"} simulates that COELSA credited it later: it responds 200 with the transfer in submitted and status_reason: late_settlement_review, the amount is debited with the compensating entry and transfer.outgoing.reopened arrives.
  2. POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review with {"outcome":"completed"} simulates that two people at Grip Rails confirm the settlement (in the sandbox, two synthetic operators, through the same path as in production): it responds 202 with the transfer still reopened, and a few seconds later transfer.outgoing.completed arrives, free of charge.

As in production, after the first step your outgoing transfers are disabled (403 entity_suspended). In the sandbox only, a few seconds after the transfer.outgoing.completed of the second step Grip Rails enables them again on its own (GET /v1/entity is back to active; not if Grip Rails blocked your entity in the meantime) and you can keep testing. Not in production: they stay disabled until Grip Rails re-enables them.

Aliases (the human-readable names of Argentine accounts) for validating counterparties:

Alias Validation Transfer
sbx.valida valid authorized
sbx.inactiva inactive destination_inactive
sbx.inexistente not_found destination_not_found
sbx.bloqueada blocked compliance_rejected
sbx.otro.titular holder_mismatch if you send expected_cuit destination_holder_mismatch

CBUs (Argentine bank account numbers) and CVUs with valid check digits are also accepted, with a synthetic holder.

A minimal sandbox flow is:

POST /v1/simulations/incoming_transfers
  -> credit posted, event transfer.incoming.posted and webhook

POST /v1/simulations/outgoing_transfers/{id}/rail_result
  {"outcome":"completed"|"failed", "coelsa_code":"7502"}
  (for a submitted transfer; completed with a rejection code is 400 parameter_invalid;
   on a failed one that went out through COELSA, only completed: the late settlement)

POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review
  {"outcome":"completed"}
  (only for a reopened one: 202, Grip Rails closes it as completed free of charge and, in the sandbox only,
   then enables your outgoing transfers again)

GET  /v1/simulations/clock
POST /v1/simulations/clock/advance {"advance_seconds":86400}

The clock only moves forward and affects daily/monthly limits, webhook retries and expirations. Simulation POSTs also carry a signature and an Idempotency-Key.

6. Conformance suite

Before going to production, ask Grip Rails for the conformance suite: test cases in JSON to run against your integration, with sample keys. Those keys are public: never register them in any environment.

Errors are delivered as {error:{type,code,message,display_message,param,request_id,retryable,doc_url,transfer_id}}. For a retryable: true error, repeat the same request with the same Idempotency-Key; never make up a new key for a transfer whose response was lost.