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:
- the company's
entity_id; - an API key for the chosen environment; and
- the public key for signing requests (
ed25519orecdsa-p256-sha256), with itskeyid(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:
- Serialize the body only once and keep those exact bytes.
- Compute
Content-Digest: sha-256=:<base64>:over those bytes. - Generate a new
Idempotency-Keyfor each intent. If the response is lost, repeat the same request with the same key. - Build the RFC 9421 HTTP signature covering
@methodand@target-uri; with a body, addcontent-type,content-digestandidempotency-key. If you sendFCM-Version, also coverfcm-version. Do not coverauthorization: the API key goes in the request but not in the signature (Grip Rails respondssignature_invalid). That way the signature of each order can be re-verified with your public key without knowing your API key. - Use a new random
nonceon every request (16 bytes in base64url, for example) andcreatedwith the current time: Grip Rails rejects a repeatednonce(signature_replayed) and a signature older than 5 minutes. - Send
Authorization,Signature-Input,Signature,Content-DigestandIdempotency-Keytogether 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".
# 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:
-
201with the transferreserved(orcompletedif the destination is a Grip Rails account): the authorization approved it and the money is on hold. -
422 transfer_declinedwith the transferrejected: the authorization declined it and no money moved. -
202with the transferpending:If Grip Rails cannot confirm the result right away:
202with the transfer inpending, or insubmittedwithstatus_reason: rail_outcome_unknownif 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. Apendingtransfer ends inreserved(eventtransfer.outgoing.reserved) or infailedwithfailure.code: canceled_by_fcm(eventtransfer.outgoing.failed).State What it means Hold ISO 20022 pendingGrip 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 PDNGA
202response is stored and replayed like any2xx. The current state is available atGET /v1/outgoing_transfers/{id}or arrives by webhook. Inauthorization.decision,pendingmeans that the authorization has not finished yet (decided_atis 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:
- Keep the raw bytes of the body. Verify
FCM-Signaturebefore parsing JSON. - Read
kidandalgfrom the JWS header and look up exactly that key inGET /.well-known/jwks.json(cached; for an unknownkid, refresh at most once per minute). The algorithm in the header must match the one published for thatkid. - Validate the signature,
iatwithin the last five minutes andexpstill in the future (up to 30 s of clock skew),iss(https://api.fcmpayments.com) andaudequal to yourentity_id. - Recompute
body_sha256over the raw bytes (base64url without=) and compare it with the claim. Also validateevtagainstbody.idand theFCM-Event-Idheader, andjtiagainstFCM-Delivery-Id. - Reject a
jtiyou have already processed: anti-replay is mandatory. Keep eachjtifor 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 ownjti(a retry brings anotherjtiand a higherFCM-Delivery-Attempt). - Also deduplicate by
event.idand sort bysequence. If you see 41 and then 43, recover 42 withGET /v1/events?after_sequence=41. - Respond
2xxin under ten seconds (counting from when Grip Rails starts resolving your host until it finishes reading your response) and process in the background. A2xxis 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:
- On a
failedtransfer that went out through COELSA (for example, a…13),POST /v1/simulations/outgoing_transfers/{id}/rail_resultwith{"outcome":"completed"}simulates that COELSA credited it later: it responds200with the transfer insubmittedandstatus_reason: late_settlement_review, the amount is debited with the compensating entry andtransfer.outgoing.reopenedarrives. POST /v1/simulations/outgoing_transfers/{id}/late_settlement_reviewwith{"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 responds202with the transfer still reopened, and a few seconds latertransfer.outgoing.completedarrives, 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.