Reconciliação

Para: Dev, Fin — Conferido em 2026-10-18.

GET /v1/reconciliation entrega, para cada dia UTC e cada moeda, a lista de movimentos liquidados, os totais e um digest assinado. Com ele você prova todos os dias que o seu livro e o do Dracma contam o mesmo dinheiro. Esta página descreve os parâmetros, os itens, os totais, o cálculo do digest e a verificação da assinatura.

O que é

Rota Escopo operationId Resposta
GET /v1/reconciliation read reconciliacao 200 com uma página do relatório do dia, ou 409 day_open

O relatório vem do fechamento do livro do Dracma, que é o dono do que está "liquidado". A API recalcula o digest por conta própria e o compara com o do livro: se divergirem, ela responde 503 e alerta o Dracma, em vez de entregar um relatório inconsistente.

A reconciliação tem duas rotinas no seu lado:

  1. Diária: o relatório dos movimentos liquidados em cada dia UTC, por moeda, com digest assinado (esta página).
  2. A cada 5 minutos: GET /v1/events?after= para recuperar webhooks perdidos (veja Recuperação com GET /events).

Quando usar

Uma vez por dia, depois das 02:00 UTC do dia seguinte, para cada moeda habilitada. O dia D só abre em D+1 às 02:00 UTC (24 h mais 2 h de carência). Antes disso a resposta é 409 day_open.

Quando O quê
A cada 5 min GET /v1/events?after=<último evt>: deduplique, faça o fetch-back e aplique o efeito
Diariamente, depois das 02:00 UTC GET /v1/reconciliation do dia anterior, para cada moeda: digest, assinatura e comparação com o seu livro
Continuamente GET /v1/balances para alertar caixa baixo (veja Limites do jogador e saldos)

Para a visão do financeiro, no painel, veja Reconciliação no painel.

Como funciona

Parâmetros

Parâmetro Obrigatório Conteúdo
date sim dia UTC, AAAA-MM-DD. Formato inválido (inclusive data que não existe): 400 com reason: format
currency sim moeda da carteira (BRL). Ausente: 400 com reason: required. Não habilitada: 400 currency_not_enabled
cursor não o next_cursor da página anterior. Inválido: 400 com reason: format
limit não de 1 a 200. Padrão 50. Fora da faixa: 400 com reason: range

Disponibilidade e 409 day_open

Pedir um dia que ainda não abriu responde 409 com code: day_open:

  • retryable: true;
  • Retry-After com os segundos até a abertura (no mínimo 1);
  • detail com o instante de abertura: disponível a partir de <RFC 3339 UTC>.

O erro significa "tente mais tarde". Agende a chamada para depois das 02:00 UTC e não repita em laço.

Paginação, totais e assinatura

  • A ordem é a canônica: object_id crescente e, em empate, kind crescente. O cursor é opaco.
  • totals e digest só vêm na última página, a que traz next_cursor: null. Nas outras páginas os dois vêm null.
  • O cabeçalho Dracma-Signature também só vem na última página.
  • A última página nunca sai sem assinatura. Se o assinador, o livro ou o banco estiverem fora, a resposta é 503 service_unavailable, repetível.
  • A rota usa o balde geral de leitura do rate limit. Pagine devagar mesmo assim. Veja Limites e quotas.

Os itens

Cada item de items é um movimento liquidado no dia:

Campo Tipo Conteúdo
object_id texto o id do Dracma do depósito ou saque
external_id texto o seu id
kind texto o tipo do movimento (tabela abaixo)
status texto o status do objeto naquele momento: paid, refunded, chargeback ou returned
amount Money o valor do movimento
fee Money ou null sempre null hoje
settled_at texto RFC 3339 em UTC, com segundos e sem fração

kind: o que o Dracma emite hoje

kind O que é Emitido hoje? amount settled_at
deposit depósito liquidado (paid) sim o valor pago o paid_at do depósito
withdrawal saque liquidado (paid) sim o valor do saque o paid_at do saque
refund estorno de depósito só no sandbox o valor do depósito o instante do evento
chargeback chargeback ou disputa de depósito só no sandbox o valor do depósito o instante do evento
return dinheiro devolvido: depósito returned ou saque returned sim depósito: o valor recebido (paid_amount). Saque: o return.returned_amount o instante do evento
fee tarifa não — —
recall, receipt recall aceito e recebimento não casado, da v1.1.0 não — —
  • settled_at de deposit e withdrawal é o paid_at do objeto. Nos demais kind é o instante em que o Dracma registrou o evento. Compare pelo settled_at do Dracma (UTC), não pela data local: perto da meia-noite UTC o mesmo movimento pode cair no dia vizinho do seu fuso.
  • Um depósito que foi para returned sem nunca ter sido paid aparece só com return, sem item deposit. Isso é normal.
  • Um saque devolvido aparece com dois itens: o withdrawal do dia do pagamento e o return do dia da devolução.
  • Itens fee e o campo fee do item não existem hoje: o Dracma não desconta tarifa do crédito. As tarifas são por contrato (veja Tarifas).
  • Aplique o leitor tolerante: kind desconhecido entra no digest como qualquer outro, mas não gera lançamento automático no seu livro. Vira item de revisão.

Reservado no contrato

fee (item e total), kind: fee, recall e receipt estão no contrato 1.1.0 e no OpenAPI, mas o Dracma não emite esses itens hoje. O total fees vem "0".

Os totais

totals traz seis campos, calculados sobre todos os itens do dia (não só os da página):

Campo Soma
deposits_paid itens deposit
withdrawals_paid itens withdrawal
refunds itens refund
chargebacks itens chargeback
fees itens fee e fee dos itens. Hoje sempre "0"
count quantidade de itens, inclusive return

Os totais não incluem return

A API não devolve um total de return. O item return conta em count, mas não entra em nenhuma soma. Portanto "os totais batem com a soma dos itens" só vale quando o dia não tem return. Quando tiver, some os itens return à parte e compare com o seu livro. O documento OpenAPI servido também não tem esse total.

O digest

Para cada item do dia, em todas as páginas, monte a linha canônica:

object_id|external_id|kind|status|amount|currency|settled_at
  1. Junte os itens de todas as páginas.
  2. Ordene por object_id crescente (comparação de bytes, sem locale). Em empate (o mesmo objeto em dois itens, por exemplo withdrawal e return), ordene por kind crescente. O contrato só cita object_id; o desempate por kind é o que o Dracma faz (veja Divergências conhecidas).
  3. Una as linhas com \n. Pela definição de "unir", não há \n no final.
  4. digest = hex( sha256( linhas_unidas ) ), em minúsculas.
  5. O resultado tem de ser igual ao digest da última página.

Detalhes que costumam errar:

  • amount é o inteiro na menor unidade como vem no JSON ("5000", não 50.00).
  • currency da linha é a moeda do item, igual a amount.currency e ao currency do topo.
  • settled_at é a string do JSON, sem reformatar.
  • Dia sem movimento: items: [] e digest igual ao sha256 de texto vazio, e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

A assinatura do digest

Dracma-Signature: t=<ts>,v1=hex( HMAC-SHA256( segredo_de_webhook, "<t>." + digest ) )
  • O segredo é o segredo de webhook da operação (não o de chamada à API), com a mesma rotação do webhook. Podem vir dois v1, e basta um bater.
  • A mensagem assinada é o t, um ponto e o digest em hexadecimal (os 64 caracteres). Não é o corpo da resposta.
  • Verifique na hora do download. O Dracma assina a cada pedido da última página, então o t= é sempre recente. Aplique a mesma janela de ±300 s do webhook sobre o t=. Guarde o cabeçalho junto com o relatório como prova; uma reverificação posterior compara só o v1, sem a janela.
  • A primitiva é a mesma do webhook ("<t>." + mensagem): os clientes de Exemplos reaproveitam a verificação passando o digest no lugar do corpo. Veja Requisição e assinatura.

Fluxo completo

sequenceDiagram participant C as Seu conector participant D as API do Dracma loop até next_cursor ser null C->>D: GET /v1/reconciliation?date=D&currency=BRL&limit=200[&cursor=...] D-->>C: 200 com items (e, na última, totals, digest, Dracma-Signature) end Note over C,D: 409 day_open: agende para depois das 02:00 UTC e pare C->>C: junta os itens, ordena por object_id e kind C->>C: recalcula o digest e compara C->>C: verifica a assinatura (t, v1) C->>C: compara com o seu livro, divergência é incidente

Pseudocódigo:

itens = []
cursor = nulo
repita:
  r = GET /v1/reconciliation?date=D&currency=BRL&limit=200[&cursor=cursor]
  se r.status == 409 e r.code == "day_open": agendar para depois das 02:00 UTC; parar
  itens += r.json.items
  cursor = r.json.next_cursor
até cursor == nulo
ultima = r
linhas = [f"{i.object_id}|{i.external_id}|{i.kind}|{i.status}|{i.amount.amount}|{i.amount.currency}|{i.settled_at}"
          para i em ordenar(itens, por=(object_id, kind))]
digest = sha256_hex(juntar(linhas, "\n"))
exigir digest == ultima.json.digest                          senão incidente
sig = parse(ultima.header["Dracma-Signature"])               # t= e 1..2 v1
exigir algum v1 == HMAC_SHA256(segredo_webhook_ativo, sig.t + "." + digest)   # tempo constante
comparar_com_meu_livro(itens)

O que comparar com o seu livro

Para cada dia e cada moeda:

  • Todo deposit do Dracma tem exatamente um crédito no seu livro, com o mesmo external_id e o mesmo valor.
  • Todo crédito seu tem um deposit no relatório. Um crédito sem par é o caso mais grave: é dinheiro criado sem liquidação.
  • Todo withdrawal corresponde a uma reserva sua finalizada, e vice-versa.
  • refund, chargeback e return correspondem às decisões que você registrou. Para return, veja Reconciliar com o seu livro.
  • totals batem com a soma dos itens, exceto pelo return (veja acima).

Divergência é incidente. Não "conserte" o livro para bater. Abra o incidente, congele o que for preciso, investigue pelo trace_id e pelos eventos (GET /v1/events?object_id=) e registre. Dispare o alerta quando o digest não bate, quando a assinatura não bate ou quando falta um par.

Exemplos

GET /v1/reconciliation?date=2026-10-17&currency=BRL&limit=200 HTTP/1.1
Host: api.example.test
Dracma-Key-Id: key_test_01J9EXEMPLO
Dracma-Timestamp: 1790000400
Dracma-Nonce: 0123456789abcdef0123456789abcdef
Dracma-Signature: v1=<assinatura>

Última página de um dia com um depósito, um saque e uma devolução parcial desse saque. Repare que count é 3 e que totals não tem soma de return:

HTTP/1.1 200 OK
Dracma-Signature: t=1790000401,v1=<64 caracteres hexadecimais>
{
  "date": "2026-10-17",
  "currency": "BRL",
  "items": [
    {
      "object_id": "dep_0192a3b40c6d7e8f9a0b1c2d3e4f5a6b",
      "external_id": "dep_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
      "kind": "deposit",
      "status": "paid",
      "amount": { "amount": "5000", "currency": "BRL" },
      "fee": null,
      "settled_at": "2026-10-17T15:07:40Z"
    },
    {
      "object_id": "wdr_0192a3b40c6d7e8f9a0b1c2d3e4f5a6c",
      "external_id": "wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c",
      "kind": "return",
      "status": "returned",
      "amount": { "amount": "3000", "currency": "BRL" },
      "fee": null,
      "settled_at": "2026-10-17T23:10:00Z"
    },
    {
      "object_id": "wdr_0192a3b40c6d7e8f9a0b1c2d3e4f5a6c",
      "external_id": "wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c",
      "kind": "withdrawal",
      "status": "paid",
      "amount": { "amount": "5000", "currency": "BRL" },
      "fee": null,
      "settled_at": "2026-10-17T21:30:05Z"
    }
  ],
  "next_cursor": null,
  "totals": {
    "deposits_paid": { "amount": "5000", "currency": "BRL" },
    "withdrawals_paid": { "amount": "5000", "currency": "BRL" },
    "refunds": { "amount": "0", "currency": "BRL" },
    "chargebacks": { "amount": "0", "currency": "BRL" },
    "fees": { "amount": "0", "currency": "BRL" },
    "count": 3
  },
  "digest": "140a4741bd77e90442f0d09443876ec084158fe489d13e96529a21909e002d4f"
}

As três linhas canônicas que geram esse digest, já na ordem certa:

dep_0192a3b40c6d7e8f9a0b1c2d3e4f5a6b|dep_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b|deposit|paid|5000|BRL|2026-10-17T15:07:40Z
wdr_0192a3b40c6d7e8f9a0b1c2d3e4f5a6c|wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c|return|returned|3000|BRL|2026-10-17T23:10:00Z
wdr_0192a3b40c6d7e8f9a0b1c2d3e4f5a6c|wdr_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6c|withdrawal|paid|5000|BRL|2026-10-17T21:30:05Z

Resposta 409 para um dia que ainda não abriu (Retry-After: 3600):

{
  "type": "https://docs.dracma.test/errors/day_open",
  "title": "day_open",
  "status": 409,
  "code": "day_open",
  "detail": "disponível a partir de 2026-10-18T02:00:00Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "retryable": true
}

Dia sem movimento (items vazio, digest do texto vazio):

{
  "date": "2026-10-16",
  "currency": "BRL",
  "items": [],
  "next_cursor": null,
  "totals": {
    "deposits_paid": { "amount": "0", "currency": "BRL" },
    "withdrawals_paid": { "amount": "0", "currency": "BRL" },
    "refunds": { "amount": "0", "currency": "BRL" },
    "chargebacks": { "amount": "0", "currency": "BRL" },
    "fees": { "amount": "0", "currency": "BRL" },
    "count": 0
  },
  "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

Erros e casos de borda

Situação Resposta
date inválida, currency ausente, limit ou cursor inválidos 400 validation_failed, com o campo e o reason em errors[]
Moeda não habilitada 400 currency_not_enabled
Dia ainda não aberto 409 day_open, retryable: true, Retry-After em segundos
Livro, assinador ou banco fora 503 service_unavailable, repetível
Digest do livro diferente do recalculado pela API 503 service_unavailable, e o Dracma é alertado. Nunca entrega um relatório inconsistente
Sem o escopo read 403 insufficient_scope
  • Meia-noite UTC. Um item do dia D pode ter sido creditado por você perto da meia-noite do seu fuso. Compare pelo settled_at (UTC).
  • Digest diferente do seu. Confira primeiro a ordenação por (object_id, kind), o \n final e se você reformatou settled_at.
  • Assinatura que não bate. Confira se está usando o segredo de webhook e se a mensagem é "<t>." + digest, não o corpo.
  • Depois de uma rotação do segredo, podem vir dois v1 por um tempo. Basta um bater.
  • kind novo. Não derrube o seu job: guarde o item e abra revisão.

Veja também