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 é:
- 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.
- Assinatura verificada no
nero_paymentssobre o corpo cru (HMAC-SHA256 com o segredo de webhookwhsec_…, que é diferente do segredo de chamada). - 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 embad_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
aftertem de ser um evento da operação da Nero; id desconhecido →400(reason: unknown). Guarde o cursor no banco, nunca em memória.GET /eventsestá no balde de listagem (5 req/s por chave): 20 páginas × 200 eventos por ciclo é folgado.- Os eventos de
/eventsnão têm assinatura própria: a autenticidade vem da chamada autenticada da Nero. - Métricas:
nero_dracma_events_lag_seconds(agora −created_atdo ú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¤cy=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_ide porGET /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
DigesteVerifyDigestprontos. - 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 |