Reconciliação

Estado: GET /reconciliation e GET /balances estão implementadas (conferido no código em 2026-09-27). A assinatura do digest é feita pelo serviço dracma_webhooks, com o mesmo formato do §4. A fonte dos itens é o fechamento do livro do Dracma (o dono do "liquidado"); a API recalcula o digest e, se ele divergir do livro, responde 503 (e alerta) em vez de entregar um relatório inconsistente.

A reconciliação prova, todos os dias, que o seu livro e o Dracma contam o mesmo dinheiro. São duas rotinas:

  1. Diária: o relatório de movimentos liquidados em cada dia UTC, por moeda, com um digest assinado.
  2. A cada 5 minutos: GET /events?after= para recuperar webhooks perdidos (ver webhooks.md §8).

1. GET /v1/reconciliation

GET /v1/reconciliation?date=2026-09-26&currency=BRL&limit=200
GET /v1/reconciliation?date=2026-09-26&currency=BRL&limit=200&cursor=<next_cursor>
Parâmetro Obrigatório Conteúdo
date sim Dia UTC (AAAA-MM-DD). Formato inválido → 400 (reason: format).
currency sim Moeda da carteira (BRL no lançamento). Ausente → 400 (required); não habilitada → 400 currency_not_enabled.
cursor não O valor de next_cursor da página anterior.
limit não De 1 a 200. O padrão é 50.
  • Quando está disponível: a partir de 02:00 UTC do dia seguinte. Antes disso a resposta é 409 day_open, com retryable: true e Retry-After = segundos até a abertura. Esse erro significa "tente mais tarde": agende para depois das 02:00 UTC, não repita em laço.
  • Paginado. totals e digest só vêm na última página, que é a que tem next_cursor: null. Nas outras páginas os dois vêm null.
  • Rate limit: o contrato fala em 5 req/s; hoje a rota usa o balde geral de leitura (100 req/s por chave). Pagine devagar mesmo assim (D-A2 em divergencias.md).
  • Livro, assinador (dracma_webhooks) ou banco fora → 503 service_unavailable retryable. A última página nunca sai sem assinatura.
  • Escopo: read.

Resposta (última página):

{
  "date": "2026-09-26",
  "currency": "BRL",
  "items": [
    {"object_id": "dep_01J9ZQ3", "external_id": "dep_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
     "kind": "deposit", "status": "paid", "amount": {"amount": "5000", "currency": "BRL"},
     "fee": null, "settled_at": "2026-09-26T15:00:00Z"}
  ],
  "next_cursor": null,
  "totals": {
    "deposits_paid": {"amount": "5000", "currency": "BRL"},
    "withdrawals_paid": {"amount": "0", "currency": "BRL"},
    "refunds": {"amount": "0", "currency": "BRL"},
    "chargebacks": {"amount": "0", "currency": "BRL"},
    "fees": {"amount": "0", "currency": "BRL"},
    "count": 1
  },
  "digest": "<sha256 hex>"
}

A última página traz também o header Dracma-Signature: t=<ts>,v1=<hex>[,v1=<hex>].

kind

kind O que é
deposit Depósito liquidado (paid).
withdrawal Saque liquidado (paid).
refund Estorno de depósito.
chargeback Chargeback ou MED.
fee Tarifa.
return, recall, receipt Da v1.1.0: transferência bancária devolvida, recall aceito e recebimento não casado. Só aparecem com wire ou SWIFT, que ainda não estão disponíveis. Aplique o leitor tolerante: um kind desconhecido entra no digest como qualquer outro, mas não gera lançamento automático no seu livro; vira item de revisão.

2. 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 em ordem crescente (comparação de bytes, sem locale). Empate (o mesmo objeto em dois itens, ex.: deposit e refund do mesmo depósito): ordene por kind crescente. É o que o código do Dracma faz; o contrato só cita object_id (D-C1 em divergencias.md).
  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 campo digest da última página.

Conferido no código (internal/application/reconciliation):

  • amount é o inteiro na menor unidade exatamente 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 da resposta;
  • settled_at é RFC 3339 UTC com segundos, sem fração (2026-09-26T15:00:00Z), igual à string do JSON: use a string como veio, sem reformatar;
  • dia sem movimento: items: [], digest = sha256 de "" (e3b0c442…b855).

3. A assinatura do digest

Dracma-Signature: t=<ts>,v1=hex( HMAC-SHA256( segredo_de_webhook, "<t>." + digest ) )
  • O segredo é o segredo de webhook (não o de chamada), 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 hex (os 64 caracteres), não o corpo da resposta.
  • Verifique no momento do download (os exemplos aplicam a mesma janela de ±300 s do webhook sobre o t=): o Dracma assina a cada pedido da última página, então o t= é sempre recente. O contrato não diz se a janela vale para o digest; verificar na hora funciona nas duas leituras. Guarde o header junto com o relatório como prova — uma reverificação posterior compara só o v1, sem a janela.

Pseudocódigo:

itens = []
cursor = nulo
repita:
  r = GET /v1/reconciliation?date=D&currency=BRL&limit=200[&cursor=cursor]   # sua credencial
  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)

A primitiva é a mesma do webhook ("<t>." + mensagem). Os clientes em exemplos/ reaproveitam verificarWebhook passando o digest no lugar do corpo.

4. O que comparar com o seu livro

Para cada dia e cada moeda:

  • Todo deposit/paid 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/paid no relatório. Um crédito sem par é o caso mais grave: é dinheiro criado sem liquidação.
  • Todo withdrawal/paid corresponde a uma reserva sua que foi finalizada, e vice-versa.
  • refund e chargeback correspondem às decisões que você registrou (ver depositos.md).
  • Os totais batem com a soma dos itens.

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 /events?object_id=) e registre. A regra de alerta que o go-live-operador.md exige é disparar quando o digest não bate, quando a assinatura não bate ou quando falta um par.

Dica: um item no relatório do dia D pode ter sido creditado por você perto da meia-noite UTC do dia D − 1 ou D + 1, por causa do fuso local. Compare pelo settled_at do Dracma (UTC), não pela sua data local.

5. Caixa da operação: GET /v1/balances

GET /v1/balances devolve, por moeda, os valores available, pending_in, pending_out e as_of. É o caixa de liquidação que paga os saques. Um saque sem caixa recebe 422 insufficient_operation_balance (ver saques.md). Monitore esse valor e alerte antes de ele acabar.

6. Rotina recomendada

Quando O quê
A cada 5 min GET /events?after=<último evt> → dedup, fetch-back e efeito (ver webhooks.md §8).
Diariamente, depois das 02:00 UTC GET /reconciliation do dia anterior, para cada moeda → digest, assinatura e comparação com o livro.
Continuamente GET /balances → alerta de caixa baixo.