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:
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:
Authorization: Bearer fcm_test_….- An HTTP message signature (RFC 9421) made with your private key: components
@methodand@target-uri; with a body, alsocontent-type,content-digest(RFC 9530, over the exact bytes you send) andidempotency-key. Parameters:created(Unix seconds, ±300 s), a fresh randomnonceper request,keyid(ck_…) andalg. The API key is not signed: a signature that coversauthorizationis 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:
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):
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 withverificar-webhooks.ts,.pyor.goand the JWKS atGET /.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):
- Store the last processed
sequencein your database (0at the start). - Call
GET /v1/events?after_sequence=<last>&limit=100without a type filter: the sequence is then contiguous per entity (1, 2, 3…). Withtype=there are gaps by design and you cannot detect missing events. - Process each event in order, idempotently by its
evt_…(and by the object'sitr_…/otr_…):- if
sequenceis 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).
- if
- Store the new
sequencein the same transaction as the business effect. - If
has_moreistrue, fetch again right away fromnext_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
-
Validate the destination (recommended):
POST /v1/counterparty_validationswith{"destination":{"type":"alias","value":"sbx.valida"},"expected_cuit":"27000000006"}→201,result: validand acval_…valid for 15 minutes. -
Order it with a NEW
Idempotency-Keyper payment intent, stored in your database before sending: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":"payment-123"}Response Meaning What you do 201reservedAuthorized: the money (amount + fee, total_debit_amount) is on hold.completedright away if the destination is another Grip Rails account.Wait for completedorfailed(webhook,/v1/eventsorGET /v1/outgoing_transfers/{id}).202pendingGrip Rails could not confirm the authorization in time. Money may be on hold. Same as above. Never retry it with another key. It ends reservedorfailed(canceled_by_fcm).202submitted+status_reason: rail_outcome_unknownA retry found it sent and without a result. Wait; Grip Rails resolves it with COELSA. 422 transfer_declinedThe authorization declined it ( insufficient_funds,limit_*,destination_*,compliance_rejected…). No money moved.error.codesays why;transfer_idstaysrejected.5xxor409 idempotency_request_in_progressRetryable ( retryable: true).Repeat the same request with the same key. -
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 is409 idempotency_key_reused. The current state is also atGET /v1/outgoing_transfers?idempotency_key=…orGET /v1/idempotency_keys/{key}. Use a client timeout of 30 s or more on thisPOST: the edge waits up to 20 s for the authorization. -
States:
pending→reserved→submitted→completedorfailed(rejectedonly comes as a422response, with no event). Events:transfer.outgoing.reserved,transfer.outgoing.completed,transfer.outgoing.failed. If COELSA credits afailedtransfer later (late settlement), it goes back tosubmittedwithstatus_reason: late_settlement_review(eventtransfer.outgoing.reopened, with the amount already debited) and Grip Rails closes it ascompletedfree of charge (chargescarries anfcm_feeof0with its reason). Treatfailedas final except for that event. You can trigger it in the sandbox (step 6). -
Resolving a
…99by hand:POST /v1/simulations/outgoing_transfers/{id}/rail_resultwith{"outcome":"completed"}or{"outcome":"failed","coelsa_code":"7502"}and itsIdempotency-Key→200with the transfer. Only for asubmittedone;completedwith a rejection code is400 parameter_invalid. -
Simulating a late settlement: on a
failedtransfer that went out through COELSA (e.g. a…13),POST /v1/simulations/outgoing_transfers/{id}/rail_resultwith{"outcome":"completed"}→200with the transfer back insubmitted+status_reason: late_settlement_review, the amount debited and the eventtransfer.outgoing.reopened. ThenPOST /v1/simulations/outgoing_transfers/{id}/late_settlement_reviewwith{"outcome":"completed"}(two people at Grip Rails confirm it; in the sandbox, two synthetic operators) →202with the transfer still reopened, and a few seconds latertransfer.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 thatcompletedGrip Rails enables them again on its own (GET /v1/entityis back toactive; 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.