Webhook, job de /events e reconciliação na Nero

Três rotinas mantêm o livro da Nero igual ao do Dracma. Todas terminam no mesmo aplicador de estado (dedup → fetch-back → coerência → versão → efeito idempotente).

flowchart LR
  W["Webhook POST /api/payments/dracma/webhook"] --> A["Aplicador único"]
  E["Job a cada 5 min: GET /v1/events?after=cursor"] --> A
  R["Reconciliação diária 02:30 UTC: GET /v1/reconciliation"] --> X["Comparação com o nero_ledger"]
  A --> FB["Fetch-back GET /v1/deposits/id ou /v1/withdrawals/id"]
  FB --> L["nero_ledger: crédito, finalização ou devolução (idempotente por external_id)"]
  X -->|"divergência"| I["Incidente + alerta"]

1. Rota do webhook

POST /api/payments/dracma/webhook passa pelo portal sem autenticação de jogador. A defesa é:

  1. Caddy da bet: allowlist com os IPs de egress do Dracma só nessa rota (lista recebida no onboarding; mudança avisada com ≥ 30 dias). Camada extra, não substitui 2–3.
  2. Assinatura verificada no nero_payments sobre o corpo cru (HMAC-SHA256 com o segredo de webhook whsec_…, que é diferente do segredo de chamada).
  3. Fetch-back antes de mover dinheiro: um segredo de webhook vazado sozinho não cria dinheiro.

Ordem de verificação (a mesma do operador de referência da suíte — detalhes em ../webhooks.md):

# Passo Falha → responde
1 lê o corpo cru até 64 KiB 400
2 Dracma-Timestamp e Dracma-Signature presentes; um t=, ≥ 1 v1= 401
3 abs(agora − t) ≤ 300 s (NTP no host!) 401
4 Dracma-Timestamp = t= 401
5 algum v1 bate com algum segredo ativo (lista de 1 ou 2, tempo constante) 401
6 JSON válido 400
7 Dracma-Event-Id = id do corpo 400
8 evento já processado (dedup ≥ 7 dias) 200 sem efeito
9 fetch-back pelo object_id rede/5xx → 503 (o Dracma reentrega); 404 → 200 sem efeito
10 coerência: external_id é da Nero; amount, currency e player_ref iguais ao intent divergência → needs_decision + alerta, 200
11 version ≤ intent.dracma_version 200 sem efeito
12 aplica o estado (tabelas de deposito.md e saque.md) na mesma transação que marca o evento como visto erro interno → 500 (reentrega)
13 200 —
  • Responda em ≤ 10 s. Se o trabalho for pesado, grave o evento de forma durável e responda 200; um worker processa.
  • Nunca siga redirect nem responda 3xx: o Dracma trata como falha.
  • Tipo de evento ou estado desconhecido: 200, grava para dedup, não move dinheiro (§6.0).
  • Métrica: nero_dracma_webhook_verify_total{result} (ok, bad_signature, stale, mismatch_event_id, duplicate). Alerta em bad_signature > 0 (pode ser ataque ou rotação mal feita).

Os clientes de ../exemplos/ (Go e Node) já implementam os passos 2–5 e passam nos vetores normativos.

2. Job de GET /events a cada 5 minutos

a cada 5 min (um único executor ativo; lock no banco):
  cursor = ler_cursor("dracma_events")                  # último evt APLICADO; nulo no primeiro run
  repetir até 20 páginas:
    r = GET /v1/events?after={cursor}&limit=200          # sem after no primeiro run
    se r.status == 429: esperar Retry-After; continuar
    se r.status >= 500 ou erro de rede: parar (tenta no próximo ciclo)
    para ev em r.data:
      aplicar_evento(ev)                                 # MESMO aplicador do webhook, a partir do passo 8
      cursor = ev.id; salvar_cursor(cursor)              # só depois de aplicar
    se r.next_cursor == nulo: parar
  • after tem de ser um evento da operação da Nero; id desconhecido → 400 (reason: unknown). Guarde o cursor no banco, nunca em memória.
  • GET /events está no balde de listagem (5 req/s por chave): 20 páginas × 200 eventos por ciclo é folgado.
  • Os eventos de /events não têm assinatura própria: a autenticidade vem da chamada autenticada da Nero.
  • Métricas: nero_dracma_events_lag_seconds (agora − created_at do último aplicado) e alerta se o job não roda com sucesso há mais de 15 min.

3. Reconciliação diária

todo dia às 02:30 UTC, para D = ontem (UTC):
  itens = []; cursor = nulo
  repetir:
    r = GET /v1/reconciliation?date=D&currency=BRL&limit=200[&cursor=…]
    se r.status == 409 (day_open): reagendar para depois do Retry-After; parar
    itens += r.items; cursor = r.next_cursor
  até cursor == nulo
  exigir sha256(linhas ordenadas por (object_id, kind)) == r.digest          # senão incidente
  exigir Dracma-Signature da última página válida com o segredo de webhook  # senão incidente
  comparar com o nero_ledger:
    todo deposit/paid tem exatamente 1 crédito com o mesmo external_id e valor
    todo crédito da Nero tem 1 deposit/paid                                  # crédito sem par é o mais grave
    todo withdrawal/paid corresponde a uma reserva finalizada, e vice-versa
  guardar relatório + header de assinatura como prova
  • Divergência é incidente: não "conserte" o livro para bater. Congele o que for preciso e investigue pelo trace_id e por GET /v1/events?object_id=.
  • Compare pelo settled_at (UTC) do Dracma, não pela data local de Brasília: itens perto da meia-noite UTC caem no dia seguinte do calendário local.
  • O Go e o Node de ../exemplos/ têm Digest e VerifyDigest prontos.
  • Métrica: nero_dracma_reconciliation_mismatch_total; alerta crítico em qualquer valor > 0.

4. Alertas mínimos do lado da Nero

Sinal Limiar Severidade
nero_dracma_outbox_pending com intent mais velho que 10 min P2
webhook com assinatura inválida > 0 em 5 min P2
401/403 nas chamadas da Nero ao Dracma > 0 P2 (credencial, relógio ou allowlist)
desvio de relógio (server_time de GET /v1/ping) > 5 s P2
job de /events sem sucesso > 15 min P2
fila needs_decision com caso parado > 4 h aviso, > 24 h crítico (D11) P2 / P1
nero_dracma_fetchback_mismatch_total > 0 P1
reconciliação diária com divergência > 0 P1