Erros e idempotência¶
Dinheiro não admite "talvez tenha ido". Esta página diz, para cada resposta possível, se você repete e como, e garante que uma repetição nunca cria um segundo depósito ou um segundo saque.
Estado: tudo o que esta página descreve está implementado (conferido no código em 2026-09-27). Os poucos códigos do catálogo que o código ainda não emite estão marcados na tabela da seção 5.
1. A regra de ouro¶
4xx não se repete (é decisão). 5xx, 429 e erro de rede se repetem com a MESMA
Idempotency-Keyaté uma resposta conclusiva. Nunca gere chave nova para "tentar de novo" um POST de dinheiro.
| Resposta | O que significa | O que fazer |
|---|---|---|
2xx |
Feito (ou já estava feito) | Grave o resultado. |
4xx (exceto 409 idempotency_in_progress, 409 day_open e 429) |
O Dracma decidiu recusar | Não repita a mesma requisição. Corrija a causa ou encerre o fluxo. |
409 idempotency_in_progress |
A mesma chave está em execução agora | Espere Retry-After e repita com a mesma chave. |
429 rate_limited |
Limite de taxa | Espere Retry-After e repita com a mesma chave. |
5xx (503 psp_unavailable, service_unavailable, outside_operating_hours) |
Não houve efeito conclusivo | Espere Retry-After e repita com a mesma chave. |
| Timeout / conexão caiu / sem resposta | Você não sabe se houve efeito | Repita com a mesma chave. Se desistir, consulte GET /deposits?external_id= ou GET /withdrawals?external_id= antes de qualquer decisão. |
code que você não conhece |
Código novo (a lista só cresce) | Trate pela classe HTTP: 4xx não repete; 5xx/429 repete. Use também o campo retryable. |
Backoff recomendado para repetição: exponencial com jitter (ex.: 1 s, 2 s, 4 s … até 60 s), respeitando
Retry-After quando vier. Cada tentativa é uma requisição nova: novo timestamp, novo nonce, nova
assinatura, mesma Idempotency-Key, mesmo corpo byte a byte.
Por que isso importa no saque: se o POST /withdrawals deu timeout e você "devolve a reserva ao jogador"
sem saber o resultado, e o saque na verdade foi aceito, o jogador recebe o PIX e o saldo de volta.
Enquanto não houver resposta conclusiva, a reserva fica onde está.
2. Idempotency-Key¶
- Obrigatório em todo
POST, inclusivecancele as rotas/sandbox/*. Sem ele (ou fora do formato):400 validation_failedcomerrors[0] = {field: "Idempotency-Key", reason: "required"}. - Formato: 16 a 128 caracteres
[A-Za-z0-9_.:-]. - Guardado 30 dias por operação.
- A chave fica ligada ao pedido inteiro: método, path+query e corpo cru.
| Situação | Resposta |
|---|---|
Mesma chave + mesmo pedido, primeira execução já terminou com 2xx ou 4xx |
A mesma resposta (status e corpo), com o header Idempotent-Replayed: true |
| Mesma chave + pedido diferente (outro corpo ou outra rota) | 409 idempotency_key_reused (não repetível: é bug do seu lado) |
| Mesma chave enquanto a primeira ainda executa | 409 idempotency_in_progress (repetível) |
Primeira execução terminou em 5xx ou 429 |
A chave é liberada: repetir com a mesma chave executa de novo, sem duplicar |
| A execução anterior ficou presa (processo caiu no meio) | A reserva da chave expira em 30 s; depois disso a repetição executa |
Consequência importante: uma recusa 4xx fica gravada. Repetir a mesma chave devolve a mesma recusa
por 30 dias. Para tentar de novo depois de uma recusa de negócio (ex.: o jogador corrigiu o documento),
é uma operação nova: external_id novo e chave nova.
2.1 Convenção recomendada: Idempotency-Key = external_id¶
Para POST /deposits e POST /withdrawals, use o seu external_id como chave. Ex.: dep_<uuidv7> e
wdr_<uuidv7> (UUIDv7 dá ordem temporal e cabe no formato). Vantagens:
- a chave sobrevive a restart do seu processo: ela está no seu banco, junto com o registro do depósito;
- não existe como "gerar chave nova sem querer" — seria um
external_idnovo, ou seja, uma operação nova consciente; - o suporte acha tudo pelo mesmo id.
Para ações sobre um objeto (cancel), derive a chave de forma determinística, por exemplo
cnl_<external_id>.
Grave o external_id e a chave antes de chamar o Dracma (padrão outbox): se o processo morrer no meio,
o worker que retoma usa a mesma chave.
3. external_id¶
- Único por operação e por tipo (depósito e saque têm espaços separados). Até 64 caracteres
[A-Za-z0-9_.:-]. - Um segundo
POST /depositscom o mesmoexternal_ide outra chave →409 duplicate_external_id, com o objeto já existente no campoexistingdo erro. Use-o para reconciliar em vez de criar outro. - É o que liga o webhook ao seu registro: no fetch-back, confira que o
external_iddevolvido é um que você criou (webhooks.md).
4. Formato do erro¶
Content-Type: application/problem+json (RFC 9457):
{
"type": "https://docs.dracma.test/errors/insufficient_scope",
"title": "insufficient_scope",
"status": 403,
"code": "insufficient_scope",
"detail": "",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"retryable": false
}
| Campo | Uso |
|---|---|
code |
Estável. É o que seu código testa. Nunca faça parse de title ou detail. |
retryable |
Se vale repetir (com a mesma chave). |
trace_id |
Também no header Dracma-Trace-Id. Guarde no log; é o que o suporte pede. |
errors[] |
Em validation_failed: field, message e reason (máquina). |
existing |
Em 409 duplicate_external_id: o objeto já existente. |
Exemplo de validação (campo desconhecido no corpo — o servidor recusa additionalProperties):
{
"type": "https://docs.dracma.test/errors/validation_failed",
"title": "validation_failed",
"status": 400,
"code": "validation_failed",
"detail": "",
"trace_id": "…",
"retryable": false,
"errors": [ { "field": "operation_id", "message": "campo não permitido", "reason": "unknown_field" } ]
}
4.1 errors[].reason que o código emite¶
Lista aberta: trate reason desconhecido como erro de validação genérico. Nunca faça parse de message.
reason |
Onde aparece |
|---|---|
unknown_field |
campo fora do schema (inclusive status, version, paid_amount, operation_id aninhado; method_options/fx_quote_id no depósito) |
duplicate_key |
chave JSON repetida em qualquer nível |
invalid_utf8 |
corpo com UTF-8 inválido |
syntax |
JSON inválido |
trailing_data |
conteúdo depois do objeto JSON |
type |
tipo errado (ex.: número onde vai string) |
invalid / invalid_json |
corpo inválido por outro motivo (ex.: POST /fx/quotes) |
required |
campo obrigatório ausente; também o header Idempotency-Key ausente ou fora do formato |
format |
formato inválido (external_id, external_ref, documento, país, IP, e-mail, updated_since, cursor, after, status do filtro de depósito) |
enum |
valor fora do enum (method, player.document.type, destination.pix.key_type, status do filtro de saque) |
range |
faixa (expires_in_seconds, metadata > 20 chaves, limit fora de 1–200, name/device_id longos no saque) |
one_of |
destination com zero ou mais de um método |
method_mismatch |
o método do destination difere de method |
currency_mismatch |
paid_amount.currency diferente da moeda do depósito em /sandbox/deposits/{id}/pay |
unknown |
after de GET /events que não é um evento da sua operação |
too_large |
corpo acima de 64 KiB (status 413) |
required_by_operation |
campo exigido pela operação ausente (ex.: player.email com Mercado Pago) — depositos.md |
max_length |
player.email > 254 ou player.name > 120 no depósito |
reserved_domain_required |
fora de produção, player.email que não é de .test/.invalid/.example |
5. Catálogo de code¶
Estável; códigos novos só se acrescentam. Retryable = repetir com a MESMA Idempotency-Key.
| HTTP | code |
Retryable | Quando | O que fazer |
|---|---|---|---|---|
| 400 | validation_failed |
não | Corpo/campo/header inválido (errors[]) |
Corrija o pedido. É bug do seu lado. |
| 400 | currency_not_enabled |
não | Moeda não habilitada na operação | Configure no onboarding. |
| 400 | method_not_enabled |
não | Método não habilitado para a moeda (hoje: tudo exceto pix/BRL) |
Não ofereça o método. |
| 400 | validation_failed com required_by_operation |
não | A operação exige um campo que o contrato deixa opcional (ex.: player.email com Mercado Pago) |
Mande o campo; external_id novo. |
| 401 | invalid_signature |
não | Assinatura não confere, chave inexistente (bem formada), nonce mal formado, header ausente; também IP fora da allowlist quando a assinatura não confere | Compare seu canônico com os vetores (autenticacao.md). |
| 401 | timestamp_out_of_window |
não | abs(agora − ts) > 300 s ou timestamp não numérico |
Corrija o relógio (NTP); timestamp em segundos. |
| 401 | replayed_nonce |
não | Nonce repetido para a chave | Gere nonce novo por requisição (CSPRNG). |
| 401 | unknown_key |
não | Key id mal formado ou de outro ambiente (pelo prefixo); chave revogada (só com assinatura válida) | Confira key_test_ x key_live_ e a base URL. |
| 403 | ip_not_allowed |
não | IP de saída fora da allowlist (só com assinatura válida) | Cadastre o IP no painel (4-eyes). |
| 403 | insufficient_scope |
não | A chave não tem o escopo da rota | Use a chave certa (autenticacao.md). |
| 403 | operation_suspended |
não | Operação suspensa pelo Dracma | Fale com o suporte. |
| 404 | not_found |
não | Não existe nesta operação — inclusive objeto de outra operação (nunca 403) | Confira o id e a credencial. |
| 409 | idempotency_key_reused |
não | Mesma chave, pedido diferente | Bug: você reutilizou chave para outra coisa. |
| 409 | duplicate_external_id |
não | external_id já usado com outra chave |
Leia existing e concilie. |
| 409 | invalid_state_transition |
não | Ex.: cancelar saque já em processing, cancelar depósito expirado |
Consulte o estado atual. |
| 409 | idempotency_in_progress |
sim | A mesma chave está executando | Espere e repita. |
| 409 | day_open |
sim (retryable: true, Retry-After = segundos até D+1 02:00 UTC) |
Reconciliação de um dia ainda aberto | Agende para depois do Retry-After; não repita em laço. |
| 412 | etag_mismatch |
não | If-Match desatualizado em /rules/change-requests |
Releia GET /rules e reenvie. |
| 413 | validation_failed (reason: too_large) |
não | Corpo > 64 KiB | Reduza o corpo (ex.: metadata). |
| 422 | amount_below_minimum / amount_above_maximum |
não | Limites por transação | Mostre o limite ao jogador. |
| 422 | document_invalid / document_mismatch |
não | Titularidade (documento inválido ou diferente do fixado para o jogador) | Revise o cadastro do jogador. |
| 422 | player_blocked / destination_blocked |
não | Listas de bloqueio (mensagem genérica de propósito) | Não revele o motivo ao jogador. |
| 422 | insufficient_operation_balance |
não* | Saque sem caixa na operação | *Pode tentar mais tarde como operação nova (novo external_id, nova chave). Alerte o financeiro. |
| 422 | velocity_exceeded |
não | Limite duro de janela/velocidade | Mostre "tente mais tarde". |
| 422 | fx_not_enabled |
não | POST /fx/quotes (câmbio desligado em toda operação) |
Não use câmbio. |
| 429 | rate_limited |
sim | Rate limit (Retry-After), inclusive 10 depósitos/h e 3 saques/h por jogador e 20 falhas de assinatura/min por IP |
Espere e repita com a mesma chave. |
| 500 | internal_error |
sim | Erro interno (detalhe só no log do Dracma, pelo trace_id) |
Repita com a mesma chave (a chave foi liberada); se persistir, abra chamado com o trace_id. |
| 503 | service_unavailable |
sim | Dependência do Dracma fora (motor de regras, cofre, banco, limitador, executor) ou queda simulada no sandbox (Retry-After) |
Espere e repita com a mesma chave. |
| 503 | psp_unavailable / outside_operating_hours |
sim | Do contrato; o código atual ainda não emite (usa service_unavailable) |
Mesmo tratamento. |
Códigos do contrato 1.1.0 para métodos ainda não disponíveis (você só os verá quando wire/swift/câmbio
forem habilitados): rail_not_enabled (400), fx_quote_expired, fx_quote_mismatch,
corridor_not_allowed, beneficiary_bank_unreachable, recall_window_expired (422).
Rota inexistente em /v1 (inclusive /sandbox/* em produção) → 404 not_found. No recorte PIX,
GET /fx/quotes/{id} e GET /receipts/{id} também respondem 404 not_found (não existe cotação nem
recebimento), POST /withdrawals/{id}/recall responde 409 invalid_state_transition e
POST /sandbox/bank-transfers/incoming responde 400 rail_not_enabled (referencia/openapi.md).
5.1 Recusa de negócio não cria objeto¶
Um 4xx em POST /deposits ou POST /withdrawals não cria depósito nem saque. No saque, isso quer
dizer: devolva a reserva ao jogador ao receber um 4xx conclusivo (e só então). A mesma chave vai
devolver a mesma recusa; nova tentativa = novo external_id.
Os motivos de revisão (quando o saque é aceito mas vai para under_review) não são erros: vêm em
decision.reasons (saques.md).
6. Árvore de decisão do conector (POST de dinheiro)¶
flowchart TD
A[Gravou external_id + Idempotency-Key no seu banco] --> B[POST assinado]
B -->|2xx| C[Grave o objeto; siga por webhook + fetch-back]
B -->|Idempotent-Replayed: true| C
B -->|4xx conclusivo| D[Encerre: não cria objeto. Saque: devolva a reserva]
B -->|409 idempotency_in_progress, 429, 5xx| E[Espere Retry-After/backoff]
B -->|timeout / rede| E
E --> F{Tentativas esgotadas?}
F -->|não| B
F -->|sim| G[Não decida às cegas: GET ?external_id= e marque para revisão + alerta]