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 rotasGET /deposits/{id},GET /withdrawals/{id}eGET /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_crusã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
hexvem em minúsculas. Compare os bytes decodificados com uma função de tempo constante:hmac.Equal(Go),crypto.timingSafeEqual(Node) ouhash_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:
typedesconhecido, oustatusdesconhecido 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 eGET /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 comversionmenor 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, umpaidsem ter recebido opendingantes.
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óximoafter.null= acabou. operation_idde 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
evtque 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
/eventsnã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 porid, fetch-back, coerência, versão e efeito idempotente. - Rate limit:
GET /eventsestá 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
v1usa 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º
v1bate. - Um receptor que ainda tem só o segredo anterior também aceita, porque o 2º
v1bate. 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":4no lugar de3), mas o header é a assinatura do vetor 1. - O HMAC correto desse corpo seria
47ab23d3…c77a6995, e ele não bate com4f82d5da…. 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.