RAILS
Sandbox

Quickstart: the sandbox of the business clients API

Integrate with Grip Rails without real money: ARS pay-ins (credits to your CVUs) and ARS payouts (transfers to a CBU, CVU or alias). The sandbox goes through the same path as production (authorization, ledger hold, fees, events and webhooks), with a simulated COELSA rail, a separate ledger and a separate database. This is the short path; details are in the integration guide (Spanish) and the contract is openapi.yaml. Versión en castellano: Inicio rápido (castellano).

0. What you get at onboarding (and what you send us)

What it is
Base URL The one we give you at onboarding; today https://empresas-sandbox.fcmpayments.com. Keep it in configuration (e.g. FCM_BASE_URL), not in code.
entity_id ent_…, your entity (environment: test).
Funds account acc_…: payouts are debited from it and pay-ins are credited to it.
Account numbers One or more acn_…, each with its CVU (synthetic in the sandbox).
API key fcm_test_…. Delivered encrypted to your PGP key (or through a one-time link). Never over chat or plain email.
Signing keyid ck_…: the id of YOUR public key, registered by Grip Rails.

What you send us first: only the public key you sign requests with (Ed25519 or P-256, PEM), your PGP public key to receive the API key and, if you want webhooks, your URL (https on 443, TLS 1.2+, no redirects, answers in under 10 s). The private key never leaves your systems:

BASH
openssl genpkey -algorithm ed25519 -out fcm-signing-private.pem
openssl pkey -in fcm-signing-private.pem -pubout -out fcm-signing-public.pem   # send us this one

1. Authentication: API key plus a signature on every request

Every request carries both:

  1. Authorization: Bearer fcm_test_….
  2. An HTTP message signature (RFC 9421) made with your private key: components @method and @target-uri; with a body, also content-type, content-digest (RFC 9530, over the exact bytes you send) and idempotency-key. Parameters: created (Unix seconds, ±300 s), a fresh random nonce per request, keyid (ck_…) and alg. The API key is not signed: a signature that covers authorization is rejected (signature_invalid).

@target-uri is the onboarding base URL + path + query, exactly as sent on the wire. Ready-to-copy signers: firma-pedidos.ts, .py and .go.

Every POST carries an Idempotency-Key (16 to 128 characters A-Z a-z 0-9 . _ : -; a UUID works). Per-entity limits: 5 writes and 25 reads per second; above that, 429 with Retry-After. Usual first errors: signature_expired (clock), signature_replayed (reused nonce), content_digest_mismatch (sent bytes differ from the signed ones) and environment_mismatch (an fcm_live_ key against the sandbox).

End-to-end example: ejemplo-sandbox.ts does everything in this guide (Node ≥ 22, no dependencies; comments in Spanish). With firma-pedidos.ts next to it:

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-signing-private.pem npx tsx ejemplo-sandbox.ts

2. Magic values of the simulated rail

Payout amount (in cents; the ending decides):

Ends in Outcome
…13 failed, failure.code: destination_closed (COELSA 7502)
…70 failed, failure.code: rail_expired (COELSA 7570)
…99 stays submitted until you resolve it with rail_result (§4)
anything else completed after ~2 s

Destinations to validate and pay:

Destination Validation Transfer
alias sbx.valida valid (holder CUIT 27000000006) authorized
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 if you send expected_cuit 422 destination_holder_mismatch
any CBU or CVU with valid check digits valid, synthetic holder authorized

CUIT 30000000007 always matches the sanctions screening. Sandbox fees and limits are examples (see them in GET /v1/entity): payouts ARS 5 fixed, pay-ins 1 %; limits of ARS 1,000,000 per transfer, 5,000,000 per day and 50,000,000 per month.

3. ARS pay-ins

Simulate a credit (in production, the COELSA notice of a transfer to your CVU):

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

It answers 201 with the posted credit: amount, net_amount (after the pay-in fee), payer, CVU and ledger_references. You learn about it in two ways, and you can use both:

  • Webhook transfer.incoming.posted, signed as a JWS (verify it with verificar-webhooks.ts, .py or .go and the JWKS at GET /.well-known/jwks.json). It is thin: it carries the id, not the amount.
  • Polling GET /v1/events (no webhooks, or to reconcile what the webhook announced).

Polling algorithm (every 2–5 s; one call per poll plus one per credit, far below the limits):

  1. Store the last processed sequence in your database (0 at the start).
  2. Call GET /v1/events?after_sequence=<last>&limit=100 without a type filter: the sequence is then contiguous per entity (1, 2, 3…). With type= there are gaps by design and you cannot detect missing events.
  3. Process each event in order, idempotently by its evt_… (and by the object's itr_…/otr_…):
    • if sequence is not last + 1 there is a gap: that is a Grip Rails error, tell us and do not move the cursor;
    • transfer.incoming.posted → GET /v1/incoming_transfers/{data.id} for amount, fee, payer and CVU;
    • ignore event types you do not know (new types can appear without a version change).
  4. Store the new sequence in the same transaction as the business effect.
  5. If has_more is true, fetch again right away from next_after_sequence; otherwise wait for the next poll.

Events are kept for 90 days. GET /v1/incoming_transfers?account_number_id=…&created_after=… also works, but it lists newest first and cannot detect gaps.

4. ARS payouts

  1. Validate the destination (recommended): POST /v1/counterparty_validations with {"destination":{"type":"alias","value":"sbx.valida"},"expected_cuit":"27000000006"} → 201, result: valid and a cval_… valid for 15 minutes.

  2. Order it with a NEW Idempotency-Key per payment intent, stored in your database before sending:

    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":"payment-123"}
    
    Response Meaning What you do
    201 reserved Authorized: the money (amount + fee, total_debit_amount) is on hold. completed right away if the destination is another Grip Rails account. Wait for completed or failed (webhook, /v1/events or GET /v1/outgoing_transfers/{id}).
    202 pending Grip Rails could not confirm the authorization in time. Money may be on hold. Same as above. Never retry it with another key. It ends reserved or failed (canceled_by_fcm).
    202 submitted + status_reason: rail_outcome_unknown A retry found it sent and without a result. Wait; Grip Rails resolves it with COELSA.
    422 transfer_declined The authorization declined it (insufficient_funds, limit_*, destination_*, compliance_rejected…). No money moved. error.code says why; transfer_id stays rejected.
    5xx or 409 idempotency_request_in_progress Retryable (retryable: true). Repeat the same request with the same key.
  3. If the response is lost, repeat the same request with the same key: you get the stored response (Idempotent-Replayed: true) or the current state. The same key with another body is 409 idempotency_key_reused. The current state is also at GET /v1/outgoing_transfers?idempotency_key=… or GET /v1/idempotency_keys/{key}. Use a client timeout of 30 s or more on this POST: the edge waits up to 20 s for the authorization.

  4. States: pending → reserved → submitted → completed or failed (rejected only comes as a 422 response, with no event). Events: transfer.outgoing.reserved, transfer.outgoing.completed, transfer.outgoing.failed. If COELSA credits a failed transfer later (late settlement), it goes back to submitted with status_reason: late_settlement_review (event transfer.outgoing.reopened, with the amount already debited) and Grip Rails closes it as completed free of charge (charges carries an fcm_fee of 0 with its reason). Treat failed as final except for that event. You can trigger it in the sandbox (step 6).

  5. Resolving a …99 by hand: POST /v1/simulations/outgoing_transfers/{id}/rail_result with {"outcome":"completed"} or {"outcome":"failed","coelsa_code":"7502"} and its Idempotency-Key → 200 with the transfer. Only for a submitted one; completed with a rejection code is 400 parameter_invalid.

  6. Simulating a late settlement: on a failed transfer that went out through COELSA (e.g. a …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result with {"outcome":"completed"} → 200 with the transfer back in submitted + status_reason: late_settlement_review, the amount debited and the event transfer.outgoing.reopened. Then POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review with {"outcome":"completed"} (two people at Grip Rails confirm it; in the sandbox, two synthetic operators) → 202 with the transfer still reopened, and a few seconds later transfer.outgoing.completed, free of charge. As in production, from the first step on your payouts are disabled (403 entity_suspended). In the sandbox only, a few seconds after that completed 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); in production they stay disabled until Grip Rails re-enables them.

5. Simulation clock

Each sandbox entity has its own clock: GET /v1/simulations/clock → {"now":"…","offset_seconds":0}, and POST /v1/simulations/clock/advance with {"advance_seconds":86400} (up to 31 days per request, with an Idempotency-Key) moves it forward, never back. It moves the Buenos Aires business day and month (daily and monthly limits), pending webhook retries and expirations. For example: hit the daily limit (422 limit_daily_exceeded), advance one day, and the same order with a new key goes through again.

6. Before production

Production changes the base URL (https://api.fcmpayments.com), the key (fcm_live_…, a different one, with your production public key) and the money, which is real: COELSA transfers are irreversible. /v1/simulations/* does not exist (404), and webhooks are signed with a KMS key that is published in the JWKS 7 days before it is used. Everything else is the same contract: if your integration passes the end-to-end example in the sandbox, it speaks production.