Webhooks: receber, verificar e agir

Aviso: esta página segue o Contrato v1 1.1.0 (§4, §5, §6.0 e §8). Se houver diferença, vale o que o contrato diz. O formato do emissor (headers, assinatura, cronograma de reentrega) foi conferido no serviço dracma_webhooks. As rotas GET /deposits/{id}, GET /withdrawals/{id} e GET /events, que o receptor usa, estão implementadas na API pública (conferido no código em 2026-09-27).

O Dracma avisa cada mudança de estado de um depósito ou saque com um POST na URL de webhook cadastrada para a sua operação, por exemplo https://pagamentos.operador.example/dracma/webhook. O webhook só avisa. Quem decide se o dinheiro se move é o estado que a API devolve quando você consulta o objeto (o fetch-back, descrito abaixo).

1. O que chega

POST /dracma/webhook HTTP/1.1
Host: pagamentos.operador.example
Content-Type: application/json; charset=utf-8
Dracma-Event-Id: evt_01J9ZQ5
Dracma-Timestamp: 1790000300
Dracma-Signature: t=1790000300,v1=4f82d5dac571d1a7b4dae9ef852c74cffcb6a4ce76f7f3209d685798e02a229b

{"id":"evt_01J9ZQ5","type":"deposit.paid","created_at":"2026-09-26T15:00:00Z","operation_id":"op_nero_hml","object_id":"dep_01J9ZQ3","object_version":3,"data":{"object":{"id":"dep_01J9ZQ3","external_id":"dep_0192a3b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b","status":"paid"}}}
Header Conteúdo
Dracma-Event-Id Id do evento (evt_…). É igual ao id do corpo. É a chave de deduplicação.
Dracma-Timestamp Segundos Unix (UTC) do envio. É igual ao t= da assinatura.
Dracma-Signature t=<timestamp>,v1=<hex>. Durante a rotação do segredo chegam dois v1: t=…,v1=<hex>,v1=<hex>.

O envelope do corpo (Evento no openapi) tem estes campos: id, type, created_at, operation_id, object_id, object_version e data.object. O data.object traz o depósito ou saque como estava no momento da transição (mesma forma do GET).

No código atual: id é evt_<32 hex>, operation_id é o UUID da sua operação (o op_nero_hml do exemplo acima é o valor do vetor normativo, não o formato real), object_id é dep_<32 hex> ou wdr_<32 hex> e type é <objeto>.<estado alcançado> (sem sufixo de versão).

O Dracma gera um novo t= e uma nova assinatura em cada tentativa. Por isso uma reentrega de horas depois continua dentro da janela de 300 s.

2. A assinatura

v1 = hex( HMAC-SHA256( segredo_de_webhook, "<t>." + corpo_cru ) )
  • O segredo de webhook (whsec_…) é outro segredo, diferente do segredo de chamada (sk_…, ver autenticacao.md). Guarde os dois no cofre, cada um separado.
  • corpo_cru são os bytes exatos recebidos. Calcule a assinatura antes de interpretar o JSON e nunca sobre um JSON serializado de novo, porque a reserialização muda os bytes.
  • O hex vem em minúsculas. Compare os bytes decodificados com uma função de tempo constante: hmac.Equal (Go), crypto.timingSafeEqual (Node) ou hash_equals (PHP). Nunca use == em string.

3. Passo a passo do receptor (nesta ordem)

Esta ordem é a do operador de referência que a suíte de conformidade usa como oráculo.

# Passo Se falhar
1 Leia o corpo cru, até 64 KiB. 400
2 Confira se os headers Dracma-Timestamp e Dracma-Signature estão presentes. Interprete Dracma-Signature como pares chave=valor separados por vírgula: exatamente um t= e pelo menos um v1=. Ignore chaves desconhecidas, que podem ser versões futuras. 401
3 Janela: abs(agora − t) ≤ 300 s. Seu relógio precisa estar sincronizado por NTP. 401
4 O valor de Dracma-Timestamp tem de ser igual ao t=. 401
5 Assinatura: aceite se qualquer v1 bater com qualquer segredo de webhook ativo. Use tempo constante e percorra todos os pares, sem sair do laço no primeiro acerto. 401
6 Interprete o JSON. 400
7 Dracma-Event-Id tem de ser igual ao id do corpo. 400 (Contrato §4.3)
8 Dedup: se o Dracma-Event-Id já foi processado com sucesso, responda 200 sem efeito. Guarde os ids por no mínimo 7 dias. 200
9 Fetch-back: para eventos de depósito ou saque, faça GET /v1/deposits/{object_id} ou GET /v1/withdrawals/{object_id} com a sua credencial (mTLS + HMAC). Erro de rede ou 5xx: responda 503 para o Dracma reentregar. 404: não mova dinheiro e responda 200.
10 Coerência: confira que o external_id foi criado por você, que amount e currency são idênticos aos que você registrou e que player_ref é idêntico. Se houver divergência: não credite, marque o caso para revisão, dispare um alerta e responda 200.
11 Versão: se version for menor ou igual à última versão que você aplicou para esse objeto, não faça nada e responda 200. 200
12 Aplique o efeito do estado devolvido pela API (seção 5). O efeito precisa ser idempotente pelo external_id: no máximo um crédito por depósito. Erro interno: 5xx (reentrega)
13 Só depois do sucesso, marque o Dracma-Event-Id como visto e responda 200.

Regras que valem para o receptor inteiro:

  • Responda em até 10 s. Depois disso o Dracma trata a tentativa como falha. Trabalho pesado vai para uma fila local. Se usar fila, só responda 200 depois de gravar o evento de forma durável.
  • A gravação de "evento visto" e o efeito no seu livro precisam ser idempotentes entre si. Há duas formas: gravar os dois na mesma transação, ou fazer o efeito idempotente pelo external_id (uma restrição única no lançamento). Assim, um crash entre um passo e outro não credita duas vezes.
  • O Dracma não segue redirect: um 3xx conta como falha. Qualquer resposta fora de 2xx provoca nova tentativa (seção 6).
  • A mensagem de erro que você devolve nunca deve conter a assinatura esperada nem o segredo.

Por que o fetch-back é obrigatório

Suponha que o seu segredo de webhook vazou. Com ele dá para forjar um deposit.paid válido. Como você não credita pelo corpo e sim pelo GET feito com a sua credencial, o webhook forjado não passa de uma consulta inútil, e nenhum dinheiro é criado. A conformidade testa exatamente isto: um webhook forjado com assinatura válida não pode gerar crédito.

4. Pseudocódigo

handler(req):
  corpo = ler_cru(req, max=64KiB)                  ou 400
  sig   = parse(req.header["Dracma-Signature"])    ou 401   # t= único, >=1 v1
  se |agora - sig.t| > 300:                        401
  se req.header["Dracma-Timestamp"] != sig.t:      401
  ok = falso
  para cada segredo em segredos_webhook_ativos:     # rotação: 1 ou 2
    esperado = HMAC_SHA256(segredo, sig.t + "." + corpo)
    para cada v em sig.v1:
      se tempo_constante_igual(hex_decode(v), esperado): ok = verdadeiro   # sem break
  se não ok:                                       401
  ev = json(corpo)                                 ou 400
  se ev.id != req.header["Dracma-Event-Id"]:       400
  se ja_processado(ev.id):                         200   # dedup >= 7 dias
  se ev.type começa com "deposit." ou "withdrawal.":
    obj, status = GET /v1/{deposits|withdrawals}/{ev.object_id}   # SUA credencial
    se erro ou status >= 500:                      503   # Dracma reentrega
    se status == 404:                              marcar_visto(ev.id); 200
    se não coerente(obj):                          revisar+alertar; marcar_visto(ev.id); 200
    se obj.version <= versao_aplicada(obj.id):     marcar_visto(ev.id); 200
    aplicar_estado(obj)          # idempotente por external_id, na mesma transação de:
  marcar_visto(ev.id)
  200

Há clientes executáveis com verificarWebhook em Go, Node, PHP e shell em exemplos/.

5. O que fazer em cada estado (PIX, v1.0)

O que manda é sempre o estado devolvido pelo fetch-back, não o type do evento. O type só diz qual objeto consultar. Os eventos podem chegar fora de ordem, podem ser coalescidos e podem ser forjados.

Eventos que o código emite hoje (recorte PIX): deposit.pending (o primeiro evento do depósito, com object_version 2 — não existe deposit.created), deposit.under_review, deposit.paid, deposit.expired, deposit.cancelled, deposit.failed, deposit.returned; withdrawal.under_review, withdrawal.approved, withdrawal.processing, withdrawal.paid, withdrawal.rejected, withdrawal.failed, withdrawal.cancelled, withdrawal.returned (este só pela ação de sandbox return). Não existe evento público withdrawal.requested. deposit.refunded/deposit.chargeback estão no contrato e hoje só são emitidos no sandbox, pelas ações refund/chargeback (divergencias.md, D-D3). Trate-os: em produção eles chegam quando o PSP tiver gatilho, e o catálogo só cresce.

Depósitos (detalhes em depositos.md):

Evento Estado no fetch-back Ação do operador
deposit.pending pending Nenhum dinheiro se move. Exiba o QR ou o copia-e-cola ao jogador.
deposit.under_review under_review Nenhum dinheiro se move. Motivos em review_reasons (PAYER_MISMATCH, AMOUNT_MISMATCH, PAID_AFTER_EXPIRY…).
deposit.paid paid Credita o jogador pelo valor que você registrou na criação, uma única vez.
deposit.expired / deposit.cancelled / deposit.failed idem Terminal. Nenhum crédito.
deposit.returned returned Terminal. Nenhum crédito: o dinheiro voltou ao pagador.
deposit.refunded / deposit.chargeback idem Débito de um depósito já creditado. A política é sua. Recomendação: não debite automaticamente; abra uma decisão manual e dispare um alerta.

Saques (detalhes em saques.md). Você reservou (debitou) o valor antes de chamar POST /withdrawals:

Evento Estado no fetch-back Ação do operador
withdrawal.under_review / withdrawal.approved / withdrawal.processing idem Nenhum dinheiro se move. A reserva continua.
withdrawal.paid paid Finaliza: a reserva vira saída definitiva. psp_reference traz o E2E id do PIX.
withdrawal.rejected / withdrawal.failed / withdrawal.cancelled idem Devolve a reserva ao jogador, uma única vez.
withdrawal.returned returned O banco devolveu o valor depois do paid, e o dinheiro voltou ao caixa da operação. A política é sua. Recomendação: não recredite automaticamente; abra uma decisão manual e dispare um alerta.

Outros:

Evento Ação do operador
rules.updated Nenhum dinheiro se move. Se quiser, releia GET /v1/rules.

Leitor tolerante (Contrato §6.0)

O catálogo só cresce. Eventos de versões mais novas, como deposit.pending_compliance, deposit.frozen, deposit.paid_partial, withdrawal.sent, withdrawal.in_transit, withdrawal.recall_* e receipt.*, pertencem a métodos que ainda não estão disponíveis (wire, SWIFT, cripto e cartão) ou a regras desligadas. Mesmo assim, o seu código tem de sobreviver a eles:

  • type desconhecido, ou status desconhecido no fetch-back: responda 200, grave o evento para o dedup, não credite, não debite, não devolva a reserva e trate o objeto como não terminal. Confira de novo depois (fetch-back e GET /events).
  • Campo desconhecido no JSON: ignore. Nunca falhe a interpretação por causa dele.
  • Dinheiro só se move em estado que você conhece e cuja regra está escrita no seu código.

6. Entrega e reentrega (Contrato §5)

  • Pelo menos uma vez (at-least-once). O mesmo evento pode chegar mais de uma vez, sempre com o mesmo Dracma-Event-Id, e o dedup resolve.
  • Toda resposta fora de 2xx, timeout (10 s) ou redirect gera nova tentativa. Os intervalos são 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h e depois uma tentativa a cada 8 h, até completar 72 h. Passado esse prazo, o evento vai para a DLQ, visível no painel, e continua disponível em GET /events.
  • A ordem não é garantida. Cada objeto tem version (no envelope: object_version), que só cresce. Se chegar um estado com version menor ou igual ao que você já aplicou, ignore e responda 200. O fetch-back resolve a corrida naturalmente, porque devolve sempre o estado mais recente.
  • Coalescência: o Dracma manda um webhook por transição, mas transições rápidas podem virar um único webhook. O data.object é o estado atual no momento do envio, então você pode ver, por exemplo, um paid sem ter recebido o pending antes.

7. Allowlist de IP de saída do Dracma (defesa extra)

O Dracma envia webhooks só de IPs de egress fixos e publicados (lista versionada, disponível no painel). Qualquer mudança é avisada com 30 dias ou mais de antecedência, com um período em que os dois conjuntos valem. Aplique essa lista no seu proxy e no serviço receptor. Ela é uma camada a mais: não substitui a assinatura nem o fetch-back. Dentro do serviço, confie em X-Forwarded-For somente quando o salto anterior for o seu proxy de borda.

8. Recuperar eventos perdidos: GET /events

GET /v1/events?after=evt_0192a3b40c6d7e8f9a0b1c2d3e4f5a6b&limit=200
GET /v1/events?object_id=dep_0192a3b40c6d7e8f9a0b1c2d3e4f5a6b
Parâmetro Regra no código atual
after id de evento (evt_…) da sua operação. Formato inválido → 400 validation_failed (reason: format); id desconhecido ou de outra operação → 400 (reason: unknown), sem confirmar se existe. Sem after: desde o primeiro evento.
object_id filtra por objeto (dep_…/wdr_…).
limit 1 a 200; padrão 50. Fora da faixa → 400 (reason: range).
  • Resposta: {"data": [Evento…], "next_cursor": "evt_…" | null}, em ordem de criação, com o mesmo envelope do webhook. next_cursor é o id do último evento da página: use-o como próximo after. null = acabou.
  • operation_id de cada evento é sempre o da sua chave; nunca aparece evento de outra operação.
  • Rode um job a cada 5 minutos (Contrato §8) a partir do último evt que você processou. Persista esse cursor no seu banco. Esse job fecha as lacunas de webhook perdido, da DLQ, de queda do seu receptor etc.
  • Os eventos de /events não vêm assinados um a um: a autenticidade vem da sua chamada autenticada (mTLS + HMAC). Mesmo assim, passe cada evento pelo mesmo caminho do webhook a partir do passo 8: dedup por id, fetch-back, coerência, versão e efeito idempotente.
  • Rate limit: GET /events está no balde de listagem: 5 req/s por chave. Pagine; não varra tudo a cada execução.

9. Rotação do segredo de webhook

  • O segredo de webhook é rotacionado em até 90 dias. A rotação é pedida no painel.
  • Durante a sobreposição (até 7 dias), o Dracma assina com os dois segredos, e por isso chegam dois v1. O seu receptor mantém uma lista de segredos ativos, com 1 ou 2 itens, e aceita se qualquer um bater.
  • Procedimento: (1) adicione o segredo novo à lista, pelo seu cofre ou pipeline de deploy, nunca à mão no servidor; (2) confirme que os webhooks continuam sendo aceitos; (3) quando o painel encerrar a sobreposição, remova o segredo antigo.
  • Revogação de emergência (segredo vazado): no painel, com efeito em até cerca de 5 min. O fetch-back garante que o vazamento não cria dinheiro nesse intervalo.

10. Exemplo trabalhado com os vetores normativos

Os vetores de exemplos/vetores-assinatura.json são públicos e de teste: nunca use esses segredos fora de teste. Toda implementação precisa reproduzi-los byte a byte. Para testar a janela de tempo, fixe "agora" em 1790000300.

Vetor 1: assinatura simples

  • Segredo: whsec_test_vetor_fedcba9876543210
  • Mensagem assinada: 1790000300. seguido do corpo cru mostrado na seção 1
  • v1 = 4f82d5dac571d1a7b4dae9ef852c74cffcb6a4ce76f7f3209d685798e02a229b
  • Resultado: aceita.

Vetor 2: rotação (dois v1)

Dracma-Signature: t=1790000300,v1=4f82d5dac571d1a7b4dae9ef852c74cffcb6a4ce76f7f3209d685798e02a229b,v1=f146fd13a7702bb83873e1f2ff96947c854086637cc252e536b18f3dd6569920
  • O primeiro v1 usa o segredo novo (whsec_test_vetor_fedcba9876543210). O segundo usa o anterior (whsec_test_vetor_ANTERIOR).
  • Um receptor que tem só o segredo novo aceita, porque o 1º v1 bate.
  • Um receptor que ainda tem só o segredo anterior também aceita, porque o 2º v1 bate. Isso é o que permite rotacionar sem perder nenhuma entrega.
  • Confira: printf '%s' '1790000300.<corpo>' | openssl dgst -sha256 -hmac whsec_test_vetor_ANTERIOR → f146fd13…6569920.

Vetor 3: negativo (DEVE recusar)

  • O corpo tem um byte alterado ("object_version":4 no lugar de 3), mas o header é a assinatura do vetor 1.
  • O HMAC correto desse corpo seria 47ab23d3…c77a6995, e ele não bate com 4f82d5da…. Resultado: 401.
  • Um receptor que aceita esse vetor está verificando outra coisa que não o corpo cru (por exemplo, o JSON reserializado ou só alguns campos).

Os testes em exemplos/ executam os três vetores em cada linguagem.