Sandbox e conformidade

Estado: as rotas /sandbox/*, /deposits, /withdrawals e /events estão implementadas (conferido no código em 2026-09-27), assim como a separação key_test_/key_live_. Esta página segue o Contrato v1 1.1.0 (§9 e §10) e diz onde o código difere (divergencias.md).

1. O que é o sandbox

Sandbox Produção
Base URL https://sandbox.dracma.example/v1 https://api.dracma.example/v1
Chave key_test_… key_live_…
Dinheiro Nenhum se move. Não há chamada a PSP real. Real
mTLS CA de sandbox Obrigatório, com uma CA distinta
Rotas /sandbox/* Existem Respondem 404
  • Nunca move dinheiro real. O PIX devolvido é um BR Code sintético: a chave do recebedor é fictícia (sandbox@dracma.invalid) e o campo de informação adicional traz o marcador SANDBOX. Nenhum banco aceita esse código. Se um copy_paste sem SANDBOX aparecer no seu ambiente de teste, pare e reporte: você está apontando para produção.
  • Cada ambiente recusa a chave do outro. Uma key_test_ em produção (ou uma key_live_ no sandbox) recebe 401 unknown_key. Isso já está implementado e evita o erro humano de apontar hml para prd. O formato do id é key_(test|live)_[A-Za-z0-9]{1,64}.
  • Webhook só para URLs cadastradas na operação de sandbox. O Dracma nunca manda para uma URL que venha no corpo de uma requisição.
  • Mesma autenticação de produção em todas as rotas, inclusive /sandbox/*: HMAC §3, nonce e janela (ver autenticacao.md).
  • Os dados de teste usam só domínios reservados (jogador1@operador.test) e CPFs de teste gerados (dígito verificador válido, sem titular real). Nunca use o e-mail ou o CPF de uma pessoa real, nem o seu.
  • O sandbox tem limites de taxa próprios, menores que os de produção. A suíte de conformidade tem um teto de objetos por execução.

2. Idempotency-Key também nas rotas /sandbox/*

Mande Idempotency-Key em TODO POST, inclusive nas rotas /sandbox/*. Use uma chave nova a cada ação de controle, por exemplo sbx_<uuidv7>.

  • A API exige a chave em todo POST e responde 400 validation_failed (field: Idempotency-Key) quando ela falta. A suíte de conformidade também manda chave em todo POST desde 2026-09-27. O openapi.yaml publicado (o que a API serve) declara o header nessas rotas; o contrato 1.1.0 não declarava (referencia/openapi.md).
  • Use uma chave nova a cada vez: repetir a mesma chave numa ação de sandbox devolve a resposta guardada (Idempotent-Replayed: true) sem executar a ação de novo. Isso é o que você quer em POST /deposits, mas confunde quando você quer, por exemplo, ligar e desligar /sandbox/outage.

3. Escopos das rotas de sandbox

Rota Escopo exigido
POST /sandbox/deposits/{id}/pay, POST /sandbox/deposits/{id}/expire, POST /sandbox/deposits/{id}/refund, POST /sandbox/deposits/{id}/chargeback deposits:write
POST /sandbox/withdrawals/{id}/{action} withdrawals:write
POST /sandbox/outage, POST /sandbox/webhooks/redeliver deposits:write ou withdrawals:write
GET /sandbox/webhooks read

Sem o escopo exigido a resposta é 403 insufficient_scope. Um objeto de outra operação responde 404, nunca 403. Em produção, toda rota /sandbox/* responde 404 (a rota "não existe"), sem executar nada.

4. Receitas: um caminho por vez

Em todas as receitas, POST significa: requisição assinada com Idempotency-Key. Espere o webhook ou consulte o objeto (fetch-back) entre um passo e outro. Os corpos completos de criação estão em depositos.md e saques.md.

4.1 Depósito pago pelo próprio jogador → paid

  1. POST /v1/deposits com player.document = CPF do jogador. A resposta traz 201 com pending e instructions.pix, ou 202 com created, que depois passa para pending.
  2. POST /v1/sandbox/deposits/{id}/pay (corpo opcional; vazio = pago pelo próprio jogador, valor exato):
    {"payer_document": {"type": "CPF", "number": "12345678909", "country": "BR"}}
    A resposta é 200 com o objeto; o paid pode chegar logo depois, pelo webhook (o pagamento percorre o simulador do PSP). Só de pending; outro estado → 409 invalid_state_transition. Simulador fora → 503.
  3. Chega o webhook deposit.paid. Faça o fetch-back com GET /v1/deposits/{id} e confira status: paid. Credite uma vez.
  4. Confira que o evento aparece em GET /v1/events?object_id={id}.

4.2 Pago por terceiro → under_review (PAYER_MISMATCH)

  • No passo 2, mande um payer_document diferente do documento do jogador, por exemplo outro CPF de teste.
  • Resultado: under_review, com review_reasons contendo PAYER_MISMATCH. Nada é creditado. A decisão (devolver ou aprovar) é tomada no painel. Para testar a saída, aprove ou devolva pelo painel de sandbox e confira que só paid credita.

4.3 Valor divergente → under_review (AMOUNT_MISMATCH)

  • No passo 2, mande "paid_amount": {"amount": "4999", "currency": "BRL"} para um depósito de 5000 (moeda diferente da do depósito → 400, reason: currency_mismatch).
  • Resultado: under_review com AMOUNT_MISMATCH. Nada é creditado. Na v1, a saída é devolver o valor.

4.4 Expiração → expired

  1. Crie o depósito e espere pending.
  2. POST /v1/sandbox/deposits/{id}/expire → expired, com o webhook deposit.expired.
  3. Tentar pagar depois (/pay) não leva a paid.
  4. POST /v1/deposits/{id}/cancel → 409 invalid_state_transition, porque expired é terminal.

4.5 Saque automático → paid

  1. Prepare um jogador com um depósito paid (receita 4.1). A regra X× conta só o que o Dracma liquidou.
  2. Reserve o valor no seu livro e chame POST /v1/withdrawals com um valor dentro de X× e review_requested: false.
  3. A resposta traz 201 com approved e decision.mode = automatic (mande player.ip: sem ele o saque nunca é automático — OWNERSHIP_UNVERIFIED).
  4. POST /v1/sandbox/withdrawals/{id}/pay (de approved ou processing). A resposta é 200 com o objeto no estado atual; o desfecho chega depois: withdrawal.processing e withdrawal.paid → fetch-back → finalize a reserva.

Atenção: por padrão, o primeiro saque de um jogador vai para revisão (FIRST_WITHDRAWAL). Para testar o caminho automático, faça antes um primeiro saque e aprove-o com /approve.

4.6 Saque acima de X× → under_review (AUTO_MULTIPLIER_EXCEEDED)

  • Peça um saque maior que X vezes o total depositado e liquidado.
  • Resultado: under_review, decision.mode = manual e decision.reasons contendo AUTO_MULTIPLIER_EXCEEDED. A reserva continua.

4.7 review_requested: true nunca é automático

  • Repita a receita 4.5 com "review_requested": true.
  • Resultado: under_review, mesmo que as regras liberassem o automático. O operador só pode tornar a decisão mais restritiva.

4.8 Decisões e resultado do PSP no saque

POST /v1/sandbox/withdrawals/{id}/{action}:

action De → para O que o seu livro faz
approve under_review → approved (e o Dracma já despacha) Nada. A reserva continua.
reject requested/under_review → rejected Devolve a reserva.
pay approved/processing → (assíncrono) processing → paid Finaliza a saída quando ler paid.
fail approved/processing → (assíncrono) failed Devolve a reserva quando ler failed.
return paid → returned (síncrono) A política é sua. Recomendação: nada automático; abra uma decisão manual e dispare um alerta.

pay e fail respondem 200 com o objeto no estado atual: o desfecho chega por withdrawal.*. Uma ação fora da máquina de estados responde 409 invalid_state_transition. As ações compliance_hold, compliance_clear, compliance_reject, send, transit, recall_accept e recall_reject são de wire ou SWIFT: em PIX respondem 409. Ação fora do enum → 400 validation_failed. Simulador fora → 503 com Retry-After: 5.

Cancelamento pelo operador: POST /v1/withdrawals/{id}/cancel. Funciona em requested, under_review e em approved antes de o comando de envio sair; depois disso, 409 (saques.md).

4.9 Queda (outage) e retomada sem duplicar

  1. POST /v1/sandbox/outage com {"down": true} (resposta {"down": true}). A partir daí, toda rota da sua operação fora de /sandbox/* responde 503 (service_unavailable), com retryable: true e Retry-After: 5. GET /v1/ping continua respondendo. Outras operações não são afetadas.
  2. POST /v1/deposits com Idempotency-Key: K → 503.
  3. POST /v1/sandbox/outage com {"down": false}, usando uma chave nova.
  4. Repita o passo 2 com a mesma chave K e o mesmo corpo → 201 ou 202. Repita mais uma vez → a mesma resposta, agora com Idempotent-Replayed: true.
  5. GET /v1/deposits?external_id=… → exatamente 1 depósito.

É esse o comportamento que o seu cliente precisa ter em produção: diante de 5xx, 429 ou erro de rede, repita com a mesma chave (ver erros-e-idempotencia.md).

4.10 Reentrega de webhook → testa o seu dedup

  1. Faça uma receita que gere um webhook (por exemplo a 4.1) e anote o Dracma-Event-Id.
  2. POST /v1/sandbox/webhooks/redeliver com {"event_id": "evt_…"} → {"event_id": "evt_…", "scheduled": <n>}. Evento de outra operação → 404; event_id inválido → 400.
  3. O seu receptor recebe o mesmo Dracma-Event-Id de novo e responde 200 sem efeito. O crédito continua sendo 1.
  4. GET /v1/sandbox/webhooks → log das entregas: evento, tentativa e o status HTTP que você devolveu. Use esse log para ver as suas respostas 401 e 5xx.

4.11 Webhook forjado não cria dinheiro

Assine você mesmo um deposit.paid com o seu segredo de webhook de sandbox, apontando para um depósito que não está pago, e envie para o seu receptor. A assinatura é válida, mas o fetch-back devolve pending. Resultado esperado: 200 e nenhum crédito (ver webhooks.md §3).

4.12 Estorno e chargeback depois de paid → refunded / chargeback

  1. Tenha um depósito paid (receita 4.1) já creditado no seu livro.
  2. POST /v1/sandbox/deposits/{id}/refund (estorno) ou POST /v1/sandbox/deposits/{id}/chargeback (MED / chargeback), corpo vazio ou {}. A resposta é 200 com o objeto em refunded/chargeback e version maior; fora de paid → 409 invalid_state_transition; campo no corpo → 400.
  3. Chega o webhook deposit.refunded ou deposit.chargeback. Faça o fetch-back e aplique a sua política de débito (recomendação: needs_decision + alerta, nunca débito automático).
  4. Essas duas rotas são aditivas ao openapi do contrato e só existem no sandbox; em produção ainda não há gatilho (divergencias.md, D-D3).

5. Suíte de conformidade (Contrato §10)

A suíte de conformidade é o teste de aceite do contrato. É um programa em Go, caixa-preta: fala só HTTP e roda contra a sua operação de sandbox. Ela traz um operador de referência que recebe os webhooks, verifica, deduplica, faz fetch-back e credita. Verde é pré-requisito do go-live (ver go-live-operador.md). A suíte é entregue pelo Dracma no onboarding, junto com as credenciais de sandbox. No repositório do Dracma ela mora em dracma_api/conformance/ (módulo Go próprio; roda com cd conformance && go test ./conformance …). O contrato ainda cita o caminho antigo nero_v2/nero_dracma_sandbox/conformidade/ (D-S2 em divergencias.md).

O que ela prova (casos obrigatórios v1.0, PIX)

Cada caso já foi visto falhar quando a regra correspondente é quebrada.

  1. Assinatura inválida, timestamp fora da janela e nonce repetido são recusados (401 com o code certo).
  2. Idempotência: a mesma chave com o mesmo corpo devolve a mesma resposta; com corpo diferente, 409 idempotency_key_reused.
  3. Depósito → pay → webhook deposit.paid assinado, com fetch-back coerente, gera exatamente 1 crédito.
  4. Um webhook reentregue com o mesmo evt responde 200 sem efeito.
  5. Expiração: expired é terminal (pagar depois não vira paid; cancelar dá 409).
  6. Saque dentro de X× → approved/processing → paid.
  7. Saque acima de X× → under_review com AUTO_MULTIPLIER_EXCEEDED.
  8. review_requested=true nunca é automático.
  9. Objeto de outra operação → 404, em todas as rotas.
  10. /sandbox/outage → 503 retryable, e a retomada com a mesma chave não duplica.

Além desses, com as flags opcionais, ela cobre testes de segurança: chave de produção contra sandbox, allowlist de IP, revogação em até 5 min e detecção nos logs.

Como rodar

cd conformance && go test ./conformance -count=1 -v \
  -dracma-url=https://sandbox.dracma.example/v1 \
  -key-id=key_test_SUAOPERACAO \
  -key-secret-file=/caminho/seguro/segredo-chamada \
  -webhook-secret-file=/caminho/seguro/segredos-webhook \
  -webhook-listen=127.0.0.1:0 \
  -webhook-url=https://pagamentos.operador.example/dracma/webhook \
  -currency=BRL
Flag Conteúdo
-dracma-url Base do sandbox, incluindo /v1.
-key-id / -key-secret-file Chave key_test_ da operação e o arquivo com o segredo. Nunca passe o segredo na linha de comando.
-webhook-secret-file Segredos de webhook ativos, um por linha (1 ou 2 durante a rotação).
-webhook-listen Endereço do listener local do operador de referência. O padrão é 127.0.0.1:0.
-webhook-url URL cadastrada na operação que chega nesse listener. O padrão é http://<listen>/webhook.
-currency Moeda da carteira. O padrão é BRL.
-wait / -webhook-wait Espera máxima por uma transição (padrão 30 s) e por um webhook (padrão 90 s).
Opcionais -key2-id/-key2-secret-file (operação de outro tenant, para o isolamento), -live-url/-live-key-id/-live-key-secret-file (recusa cruzada de ambiente), -blocked-ip-key-id/-blocked-ip-key-secret-file (allowlist), -revoke-key-id/-revoke-key-secret-file/-revoke-cmd/-revoke-deadline (revogação), -log-file (detecção), -openapi.

Os arquivos de segredo ficam fora do repositório, com permissão 0400, entregues pelo seu cofre.

A suíte testa o Dracma usando um operador de referência. Ela não testa o seu conector. Para provar o seu lado, rode as receitas da seção 4 contra o seu receptor e o seu livro, e confira: 1 crédito por depósito pago, 0 créditos com webhook forjado, reentrega sem efeito, reserva devolvida em rejected/failed/cancelled.