RAILS
Sandbox

Início rápido: o sandbox da API de empresas clientes

Para integrar com o Grip Rails sem dinheiro real: entrada de ARS (créditos nos seus CVU, os números de conta virtual argentinos) e saída de ARS (transferências para CBU, CVU ou alias). O sandbox percorre o mesmo caminho que produção (autorização, reserva no ledger, tarifas, eventos e webhooks), mas com um trilho da COELSA (a câmara de compensação argentina de transferências imediatas) simulado, outro ledger e outro banco de dados. Este guia é o caminho curto; o detalhe está no guia de integração e o contrato, na referência da API.

0. O que entregamos no cadastro (e o que você nos envia)

O que é
URL base A que passamos no cadastro; hoje, https://empresas-sandbox.fcmpayments.com. Guarde-a na configuração (por exemplo, FCM_BASE_URL), não no código.
entity_id ent_…, a sua entidade (environment: test).
Conta de fundos acc_…: daqui saem as saídas e aqui são creditadas as entradas.
Números de conta Um ou mais acn_…, cada um com o seu CVU (sintético no sandbox).
API key fcm_test_…. Chega criptografada com a sua chave PGP (ou por um link de uso único). Nunca por chat nem por e-mail sem criptografia.
keyid de assinatura ck_…: o id da SUA chave pública, registrada pelo Grip Rails.

O que você nos envia antes: só a chave pública de assinatura de requisições (Ed25519 ou P-256, PEM), a sua chave PGP pública para receber a API key e, se quiser webhooks, a sua URL (https na porta 443, TLS 1.2 ou superior, sem redirecionamentos, que responda em menos de 10 s). A chave privada nunca sai do seu sistema:

BASH
openssl genpkey -algorithm ed25519 -out fcm-firma-privada.pem
openssl pkey -in fcm-firma-privada.pem -pubout -out fcm-firma-publica.pem   # esta é a que você nos envia

1. Autenticação: API key e assinatura de cada requisição

Toda requisição leva as duas coisas:

  1. Authorization: Bearer fcm_test_….
  2. Assinatura HTTP (RFC 9421) feita com a sua chave privada: componentes @method e @target-uri; com corpo, também content-type, content-digest (RFC 9530, sobre os bytes exatos que você envia) e idempotency-key. Parâmetros: created (segundos Unix, ±300 s), nonce aleatório novo em cada requisição, keyid (ck_…) e alg. A API key não é assinada: uma assinatura que cobre authorization é rejeitada (signature_invalid).

@target-uri é a URL base do cadastro + o caminho + a query, exatamente como vão pela rede. Os assinadores prontos para copiar estão em firma-pedidos.ts, .py e .go.

Todo POST leva Idempotency-Key (16 a 128 caracteres A-Z a-z 0-9 . _ : -; um UUID serve). Limites por entidade: 5 escritas e 25 leituras por segundo; ao ultrapassar, 429 com Retry-After. Os erros típicos no começo: signature_expired (relógio), signature_replayed (você repetiu um nonce), content_digest_mismatch (você enviou bytes diferentes dos assinados) e environment_mismatch (uma key fcm_live_ no sandbox).

Exemplo de ponta a ponta: ejemplo-sandbox.ts faz tudo o que está neste guia (Node ≥ 22, sem dependências; comentários em espanhol). Com firma-pedidos.ts ao lado:

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-firma-privada.pem npx tsx ejemplo-sandbox.ts

2. Valores mágicos do trilho simulado

Valor de uma saída (em centavos; quem decide é a terminação):

Termina em Resultado
…13 failed, failure.code: destination_closed (COELSA 7502)
…70 failed, failure.code: rail_expired (COELSA 7570)
…99 fica em submitted até que você o resolva com rail_result (§4)
qualquer outra completed em ~2 s

Destinos para validar e transferir:

Destino Validação Transferência
alias sbx.valida valid (titular com CUIT 27000000006) é autorizada
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 se você enviar expected_cuit 422 destination_holder_mismatch
qualquer CBU ou CVU com dígitos verificadores válidos valid, titular sintético é autorizada

O CUIT (identificação fiscal argentina) 30000000007 sempre dá coincidência no controle de listas de sanções. As tarifas e os limites do sandbox são de exemplo (você os vê em GET /v1/entity): saída ARS 5 fixos, entrada 1%; limites de ARS 1.000.000 por transferência, ARS 5.000.000 por dia e ARS 50.000.000 por mês.

3. Entrada de ARS

Simular um crédito (o que em produção é o aviso da COELSA de uma transferência para o seu CVU):

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

Responde 201 com o crédito posted: amount, net_amount (líquido da tarifa de entrada), pagador, CVU e ledger_references. Você fica sabendo de duas maneiras, e dá para usar as duas:

  • Webhook transfer.incoming.posted, assinado com JWS (verifique-o com verificar-webhooks.ts, .py ou .go e o JWKS de GET /.well-known/jwks.json). É enxuto: traz o id, não o valor.
  • Consulta periódica de GET /v1/events (sem webhooks, ou para conciliar o que o webhook avisou).

Algoritmo de consulta periódica (a cada 2–5 s; uma chamada por consulta e uma por crédito, bem abaixo dos limites):

  1. Guarde no seu banco de dados o último sequence processado (no começo, 0).
  2. Peça GET /v1/events?after_sequence=<último>&limit=100 sem filtro de tipo: assim a sequência é contígua por entidade (1, 2, 3…). Com type= há saltos por design e você não consegue detectar lacunas.
  3. Processe cada evento em ordem, de forma idempotente pelo seu evt_… (e pelo itr_…/otr_… do objeto):
    • se sequence não for o último + 1, há uma lacuna: é um erro do Grip Rails, avise-nos e não avance o cursor;
    • transfer.incoming.posted → GET /v1/incoming_transfers/{data.id} para valor, tarifa, pagador e CVU;
    • um tipo que você não conhece é ignorado (podem aparecer tipos novos sem mudança de versão).
  4. Guarde o sequence novo na mesma transação que o efeito de negócio.
  5. Se has_more for true, peça em seguida a partir de next_after_sequence; senão, espere a próxima rodada.

Os eventos ficam 90 dias. A alternativa GET /v1/incoming_transfers?account_number_id=…&created_after=… também serve, mas vem do mais novo para o mais antigo e não detecta lacunas.

4. Saída de ARS

  1. Valide o destino (recomendado): POST /v1/counterparty_validations com {"destination":{"type":"alias","value":"sbx.valida"},"expected_cuit":"27000000006"} → 201, result: valid e um cval_… que vale por 15 minutos.

  2. Ordene com uma Idempotency-Key NOVA por intenção de pagamento, guardada no seu banco de dados antes de enviar a requisição:

    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":"pago-123"}
    
    Resposta O que significa O que você faz
    201 reserved Autorizada: o dinheiro (valor + tarifa, total_debit_amount) ficou reservado. completed direto se o destino for outra conta do Grip Rails. Espera completed ou failed (webhook, /v1/events ou GET /v1/outgoing_transfers/{id}).
    202 pending O Grip Rails não conseguiu confirmar a autorização no momento. Pode haver dinheiro reservado. O mesmo que acima. Nunca a repita com outra chave. Termina em reserved ou em failed (canceled_by_fcm).
    202 submitted + status_reason: rail_outcome_unknown Uma nova tentativa a encontrou enviada e sem resultado. Espera; o Grip Rails resolve com a COELSA.
    422 transfer_declined A autorização a rejeitou (insufficient_funds, limit_*, destination_*, compliance_rejected…). Não saiu dinheiro. O error.code diz por quê; o transfer_id fica no estado rejected.
    5xx ou 409 idempotency_request_in_progress Pode ser repetida (retryable: true). Repete a mesma requisição com a mesma chave.
  3. Se a resposta se perder, repita a mesma requisição com a mesma chave: volta a resposta guardada (Idempotent-Replayed: true) ou o estado atual. A mesma chave com outro corpo é 409 idempotency_key_reused. O estado vigente também é consultado com GET /v1/outgoing_transfers?idempotency_key=… ou GET /v1/idempotency_keys/{key}. Use um timeout de cliente de 30 s ou mais neste POST: a borda espera até 20 s pela autorização.

  4. Estados: pending → reserved → submitted → completed ou failed (rejected só chega como resposta 422, sem evento). Eventos: transfer.outgoing.reserved, transfer.outgoing.completed, transfer.outgoing.failed. Se a COELSA creditar uma failed depois (liquidação tardia), ela volta a submitted com status_reason: late_settlement_review (evento transfer.outgoing.reopened, com o valor já debitado) e o Grip Rails a fecha como completed, sem custo (charges traz um fcm_fee de 0 com o seu motivo). Trate failed como final, exceto por esse evento. No sandbox, você pode provocá-la (passo 6).

  5. Resolver um …99 manualmente: POST /v1/simulations/outgoing_transfers/{id}/rail_result com {"outcome":"completed"} ou {"outcome":"failed","coelsa_code":"7502"} e a sua Idempotency-Key → 200 com a transferência. Só para uma submitted; completed com um código de rejeição é 400 parameter_invalid.

  6. Simular uma liquidação tardia: sobre uma failed que saiu pela COELSA (por exemplo, uma …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result com {"outcome":"completed"} → 200 com a transferência em submitted + status_reason: late_settlement_review, o valor debitado e o evento transfer.outgoing.reopened. Depois, POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review com {"outcome":"completed"} (duas pessoas do Grip Rails confirmam; no sandbox, dois operadores sintéticos) → 202 com a transferência ainda reaberta e, alguns segundos depois, transfer.outgoing.completed, sem custo. Como em produção, a partir do primeiro passo as suas saídas ficam desabilitadas (403 entity_suspended). Só no sandbox, alguns segundos depois desse completed o Grip Rails volta a habilitá-las sozinho (GET /v1/entity volta a active; não se o Grip Rails tiver bloqueado a sua entidade nesse meio-tempo); em produção, continuam desabilitadas até que o Grip Rails as reabilite.

5. Relógio de simulação

Cada entidade do sandbox tem o seu relógio: GET /v1/simulations/clock → {"now":"…","offset_seconds":0}, e POST /v1/simulations/clock/advance com {"advance_seconds":86400} (até 31 dias por requisição, com Idempotency-Key) o adianta, nunca o atrasa. Move o dia e o mês de negócio de Buenos Aires (limites diários e mensais), as novas tentativas de webhooks pendentes e os vencimentos. Por exemplo: chegue ao limite diário (422 limit_daily_exceeded), adiante um dia e a mesma ordem com outra chave volta a passar.

6. Antes de produção

Em produção mudam a URL base (https://api.fcmpayments.com), a key (fcm_live_…, outra, com a sua chave pública de produção) e o dinheiro, que é real: a COELSA é irreversível. /v1/simulations/* não existe (404) e os webhooks são assinados com uma chave de KMS que é publicada no JWKS 7 dias antes de ser usada. O resto é igual: se a sua integração passar no exemplo de ponta a ponta no sandbox, o contrato é o mesmo.