RAILS
Sandbox

Integração com a API de empresas clientes

Este guia acompanha o contrato da API. Os exemplos foram escritos para poder ser copiados para um cliente real, mas devem sempre ser testados primeiro no sandbox.

Para começar pelo sandbox: o início rápido e o exemplo de ponta a ponta ejemplo-sandbox.ts.

1. Escolha o ambiente

Produção Sandbox
URL base https://api.fcmpayments.com https://empresas-sandbox.fcmpayments.com
API key fcm_live_… fcm_test_…
Dinheiro Real; a COELSA (a câmara de compensação argentina de transferências imediatas) é irreversível Simulado; ledger e trilho separados
Simulações Não disponíveis (404) /v1/simulations/*

Uma chave de um ambiente nunca serve no outro.

2. Início rápido

Antes de fazer a primeira requisição, o Grip Rails precisa ter cadastrado:

  1. o entity_id da empresa;
  2. uma API key para o ambiente escolhido; e
  3. a chave pública de assinatura de requisições (ed25519 ou ecdsa-p256-sha256), com o seu keyid (ck_…).

A chave privada fica sempre no sistema da empresa. Não é enviada ao Grip Rails, não é guardada em variáveis de ambiente compartilhadas e não é incluída em logs.

Para um POST:

  1. Serialize o corpo uma única vez e conserve esses bytes exatos.
  2. Calcule Content-Digest: sha-256=:<base64>: sobre esses bytes.
  3. Gere uma Idempotency-Key nova para cada intenção. Se a resposta se perder, repita a mesma requisição com a mesma chave.
  4. Monte a assinatura HTTP RFC 9421 cobrindo @method e @target-uri; com corpo, acrescente content-type, content-digest e idempotency-key. Se você enviar FCM-Version, cubra também fcm-version. Não cubra authorization: a API key vai na requisição, mas não na assinatura (o Grip Rails responde signature_invalid). Assim a assinatura de cada ordem pode ser verificada de novo com a sua chave pública sem conhecer a sua API key.
  5. Use um nonce aleatório novo em cada requisição (16 bytes em base64url, por exemplo) e created com a hora atual: o Grip Rails rejeita um nonce repetido (signature_replayed) e uma assinatura com mais de 5 minutos.
  6. Envie Authorization, Signature-Input, Signature, Content-Digest e Idempotency-Key junto com o corpo.

Componentes extras. Além dos obrigatórios, a borda aceita que a assinatura cubra date, content-length, @authority, @path e @query (lista fechada; qualquer outro componente é rejeitado). @authority é o host em minúsculas, @path o caminho (/ se estiver vazio) e @query a query com o seu ? (só ? se não houver). Como parâmetros da assinatura são aceitos created, expires, nonce, keyid, alg e tag, com qualquer rótulo de assinatura.

Os trechos completos estão em firma-pedidos.ts, firma-pedidos.py e firma-pedidos.go: os três geram o nonce sozinhos e só aceitam um fixo para reproduzir um vetor de teste. Todos implementam Ed25519; o adaptador de chave P-256 usa a mesma base de assinatura e declara alg="ecdsa-p256-sha256".

BASH
# Uma requisição GET não leva Idempotency-Key nem 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'

Todas as escritas (POST) exigem Idempotency-Key. A mesma chave com o mesmo método, caminho e corpo devolve a resposta original e Idempotent-Replayed: true; com outro corpo, devolve 409 idempotency_key_reused.

3. Transferências de saída: 201, 202 e 422

POST /v1/outgoing_transfers responde:

  • 201 com a transferência reserved (ou completed se o destino é uma conta do Grip Rails): a autorização aprovou e o dinheiro ficou reservado.

  • 422 transfer_declined com a transferência rejected: a autorização a rejeitou e não saiu dinheiro.

  • 202 com a transferência pending:

    Se o Grip Rails não conseguir confirmar o resultado no momento: 202 com a transferência em pending, ou em submitted com status_reason: rail_outcome_unknown se uma nova tentativa a encontrar já enviada e sem resultado. A ordem existe e está vinculada à sua Idempotency-Key: não a repita com outra chave. Uma pending termina em reserved (evento transfer.outgoing.reserved) ou em failed com failure.code: canceled_by_fcm (evento transfer.outgoing.failed).

    Estado O que significa Reserva ISO 20022
    pending O Grip Rails recebeu a ordem, mas ainda não consegue confirmar a autorização. Pode haver dinheiro reservado. Não repita com outra Idempotency-Key. Pode haver uma pendente PDNG

    Uma resposta 202 é guardada e repetida como qualquer 2xx. O estado vigente é consultado com GET /v1/outgoing_transfers/{id} ou chega por webhook. Em authorization.decision, pending quer dizer que a autorização ainda não terminou (decided_at é a hora em que ficou pendente).

Créditos internos (fcm_book). Uma transferência para um CVU (número de conta virtual argentino) de outra empresa cliente do Grip Rails é liquidada no ledger do Grip Rails, sem passar pela COELSA. A empresa que recebe vê um crédito recebido com rail.name: fcm_book; o seu rail.operation_id é o otr_… de origem (a chave de idempotência do lançamento), em vez do id da COELSA. Chega com o seu evento transfer.incoming.posted e o seu webhook, como qualquer crédito.

Liquidação tardia. Se a COELSA credita uma transferência que já informamos a você como failed (o aviso chegou tarde ou se perdeu), o dinheiro saiu: a transferência volta a submitted com status_reason: late_settlement_review, o valor é debitado com um lançamento compensatório e chega o evento transfer.outgoing.reopened (com as suas referências). O Grip Rails desabilita as suas saídas (continuam desabilitadas, mesmo depois de completed, até que o Grip Rails volte a habilitá-las) e, quando duas pessoas do Grip Rails confirmam a liquidação, a fecha como completed (evento transfer.outgoing.completed) sem custo: o Grip Rails não cobra tarifa por uma transferência reaberta, porque a reabertura não é culpa sua. charges informa isso com um fcm_fee de 0 e o seu motivo, e total_debit_amount é o valor. Trate failed como final, exceto por esse evento. No sandbox é possível provocá-la de ponta a ponta (§5); lá, diferentemente de produção, as suas saídas voltam a ser habilitadas sozinhas depois do fechamento.

4. Eventos e webhooks

O webhook é um aviso enxuto. O corpo contém o tipo, a sequência e a referência ao objeto; para o estado vigente, faça o GET correspondente. Processe-o assim:

  1. Conserve os bytes brutos do corpo. Verifique FCM-Signature antes de interpretar o JSON.
  2. Leia kid e alg do cabeçalho JWS e busque exatamente essa chave em GET /.well-known/jwks.json (em cache; diante de um kid desconhecido, atualize no máximo uma vez por minuto). O algoritmo do cabeçalho precisa coincidir com o publicado para esse kid.
  3. Valide a assinatura, iat dentro dos últimos cinco minutos e exp ainda futuro (até 30 s de margem de relógio), iss (https://api.fcmpayments.com) e aud igual ao seu entity_id.
  4. Recalcule body_sha256 sobre os bytes brutos (base64url sem =) e compare com o claim. Valide também evt contra body.id e o cabeçalho FCM-Event-Id, e jti contra FCM-Delivery-Id.
  5. Rejeite um jti que você já processou: o anti-replay é obrigatório. Guarde cada jti por pelo menos dez minutos, num armazenamento compartilhado se você tiver várias instâncias, e persista a deduplicação junto com o efeito de negócio. Cada tentativa de entrega tem o seu próprio jti (uma nova tentativa traz outro jti e um FCM-Delivery-Attempt maior).
  6. Deduplique também por event.id e ordene por sequence. Se você vir o 41 e depois o 43, recupere o 42 com GET /v1/events?after_sequence=41.
  7. Responda 2xx em menos de dez segundos (contando desde que o Grip Rails começa a resolver o seu host até terminar de ler a sua resposta) e processe em segundo plano. Um 2xx é o único sucesso; um redirecionamento é uma falha.

Ignore os claims e os tipos de evento que você não conhece: podem aparecer sem aviso de versão.

A política é pelo menos uma vez: 25 tentativas (a primeira e 24 novas tentativas), com variação aleatória de ±20%: 30s, 1m, 2m, 5m, 10m, 15m, 30m, 1h, 2h, 3h, 4h, 5h e depois a cada 8h (cerca de 4,7 dias). Os eventos ficam 90 dias em /v1/events.

A sua URL. O Grip Rails a registra no cadastro e ela é alterada mediante pedido verificado. Precisa ser https:// na porta 443, com TLS 1.2 ou superior, sem usuário, senha nem fragmento, e resolver só para endereços públicos: o Grip Rails não entrega para redes privadas nem reservadas, nem para faixas da COELSA ou do Grip Rails. Se o Grip Rails mudar a sua URL, as entregas pendentes deixam de ir para a antiga; o que ficou no caminho é reenviado para a nova a pedido, ou você o recupera com GET /v1/events.

A verificação pronta para copiar está em verificar-webhooks.ts, verificar-webhooks.py e verificar-webhooks.go. As três suportam EdDSA Ed25519 e ES256 P-256, exigem uma proteção anti-replay (trazem uma em memória, segura com threads ou goroutines, só para começar) e toleram claims novos. O Grip Rails as testa contra os vetores da suíte de conformidade.

5. Sandbox determinístico

O sandbox usa contas, ledger, eventos e webhooks próprios, e percorre o mesmo caminho que produção: um crédito simulado é lançado, cobra tarifa, emite o seu evento e dispara o webhook. Os valores mágicos permitem testar os caminhos sem tocar em dinheiro real:

Entrada Resultado
Valor termina em …13 failed, destination_closed (COELSA 7502)
Valor termina em …70 failed, rail_expired (COELSA 7570)
Valor termina em …99 Fica em submitted até injetar o resultado
Qualquer outro valor Completa-se em aproximadamente 2 s

Liquidação tardia, em dois passos:

  1. Sobre uma transferência failed que saiu pela COELSA (por exemplo, uma …13), POST /v1/simulations/outgoing_transfers/{id}/rail_result com {"outcome":"completed"} simula que a COELSA a creditou depois: responde 200 com a transferência em submitted e status_reason: late_settlement_review, o valor é debitado com o lançamento compensatório e chega transfer.outgoing.reopened.
  2. POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review com {"outcome":"completed"} simula que duas pessoas do Grip Rails confirmam a liquidação (no sandbox, dois operadores sintéticos, pelo mesmo caminho que em produção): responde 202 com a transferência ainda reaberta e, alguns segundos depois, chega transfer.outgoing.completed, sem custo.

Como em produção, depois do primeiro passo as suas transferências de saída ficam desabilitadas (403 entity_suspended). Só no sandbox, alguns segundos depois do transfer.outgoing.completed do segundo passo 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) e você pode continuar testando. Em produção, não: continuam desabilitadas até que o Grip Rails as reabilite.

Alias (os nomes legíveis das contas argentinas) para validar contrapartes:

Alias Validação Transferência
sbx.valida valid é autorizada
sbx.inactiva inactive destination_inactive
sbx.inexistente not_found destination_not_found
sbx.bloqueada blocked compliance_rejected
sbx.otro.titular holder_mismatch se você enviar expected_cuit destination_holder_mismatch

Também são aceitos CBU (números de conta bancária argentinos) e CVU com dígitos verificadores válidos, com titular sintético.

Um fluxo mínimo de sandbox é:

POST /v1/simulations/incoming_transfers
  -> crédito lançado, evento transfer.incoming.posted e webhook

POST /v1/simulations/outgoing_transfers/{id}/rail_result
  {"outcome":"completed"|"failed", "coelsa_code":"7502"}
  (para uma transferência submitted; completed com um código de rejeição é 400 parameter_invalid;
   sobre uma failed que saiu pela COELSA, só completed: a liquidação tardia)

POST /v1/simulations/outgoing_transfers/{id}/late_settlement_review
  {"outcome":"completed"}
  (só para uma reaberta: 202, o Grip Rails a fecha como completed sem custo e, só no sandbox,
   depois volta a habilitar as suas transferências de saída)

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

O relógio só avança e afeta limites diários/mensais, novas tentativas de webhooks e vencimentos. Os POST de simulação também levam assinatura e Idempotency-Key.

6. Suíte de conformidade

Antes de passar para produção, peça ao Grip Rails a suíte de conformidade: casos de teste em JSON para rodar contra a sua integração, com chaves de exemplo. Essas chaves são públicas: nunca as registre em nenhum ambiente.

Os erros são entregues como {error:{type,code,message,display_message,param,request_id,retryable,doc_url,transfer_id}}. Para um erro retryable: true, repita a mesma requisição com a mesma Idempotency-Key; nunca invente uma chave nova para uma transferência cuja resposta se perdeu.