Webhooks — Visão geral
Webhooks são o sinal oficial. Callbacks do navegador (onSuccess)
e visões do dashboard são conveniência; webhooks são a verdade.
Garantias de entrega
- Pelo menos uma vez. Um único evento pode ser entregue até 6
vezes se o seu servidor não retornar 2xx dentro do timeout.
Torne o seu handler idempotente — deduplique em
X-Delivery(o payload não tem campoevent_id; o UUID estável da entrega é a chave de idempotência). - Um evento por requisição HTTP. Sem batching.
- Isolamento por endpoint. Se você tem múltiplos endpoints registrados, cada um tem a própria trilha de entrega + retry. Uma URL de lojista lenta não consegue inanir as outras — cada host tem o próprio circuit breaker.
- Assinado. Todo payload leva um header
X-Signature(e durante a janela de 24 horas depois de uma rotação, também umX-Signature-Prev). Verifique antes de fazer qualquer coisa com o body. Veja Verificação de assinatura.
Tipos de evento que se pode assinar
| Evento | Dispara quando… |
|---|---|
payment.settled | A transferência on-chain atingiu a contagem de confirmações da rede. Use isto para marcar pedidos como pagos. |
payment.failed | Um pagamento fiat foi explicitamente rejeitado pelo provedor (atualmente: webhook do Stripe sinalizando falha). Não dispara para timeouts de cripto — esses afloram como checkout.expired, e pagamentos cripto curtos afloram como payment.underpaid. |
payment.underpaid | Fundos chegaram, mas abaixo do total do pedido (típico: taxa de transferência de stablecoin descontada do valor). |
payment.overpaid | Fundos chegaram em excesso ao total do pedido. O excedente é registrado mas não é auto-reembolsado. |
order.created | Um novo pedido foi aberto — pela sua chamada de API B2B ou por uma conversão de checkout-session. |
order.canceled | Um pedido foi movido para cancelado. O data.reason do payload distingue cancel manual de payment_timeout (pedido não pago e velho varrido pelo worker). |
order.resolved | Um pedido PARTIAL_PAID foi resolvido para PAID — o lojista aceitou o déficit. |
order.reopened | Um pedido previamente auto-cancelado (canceled_reason=payment_timeout) foi reaberto pelo lojista. |
checkout.created | Um comprador abriu o checkout para um pedido. |
checkout.completed | O fluxo do lado do comprador terminou (não implica liquidação on-chain — use payment.settled para isso). |
checkout.expired | O comprador abandonou e o TTL da sessão acabou. |
payment.refund.requested | Um registro de reembolso foi criado — seja a partir de uma chamada de API iniciada pelo lojista, seja a partir de um formulário de pedido de reembolso submetido pelo cliente. |
payment.refund.approved | Um reembolso pendente passou pelo seu workflow de aprovação. |
payment.refund.rejected | Um reembolso pendente foi negado. |
payment.refund.executed | A transferência on-chain do reembolso foi confirmada e o registro foi para o estado terminal executed. |
refund_request.created | Um token de pedido de reembolso foi criado. data.source é b2b / dashboard / renewal. Opcional inscrever-se — útil para pipelines de auditoria que acompanham qual token está ativo no momento por pedido. |
refund_request.renewal_requested | Um comprador clicou em “Pedir link novo” depois que o token expirou. Fortemente recomendado inscrever-se — é o sinal de que o widget de renovação tem um novo item para agir. |
refund_request.renewed | Uma renovação foi aprovada e um novo token substituiu o antigo. data.old_token / data.new_token formam a cadeia de auditoria. |
refund_request.canceled | Um lojista moveu um token para CANCELED pelo dashboard (por exemplo, negou um pedido de renovação, matou um link vivo). Idempotente — só a primeira transição emite. data.reason é a nota opcional do lojista. |
O dashboard busca esta lista em GET /v1/webhooks/event-types para
que o formulário de criação / edição de endpoint sempre bata com o
que a plataforma efetivamente emite. Tentar se inscrever em um
evento que a gente não dispara é rejeitado na criação com um erro
claro.
Eventos de teste não são assináveis. O botão Send Test por
endpoint no dashboard faz POST de um envelope webhook.test.ping
síncrono para aquele único endpoint (contornando a pipeline de
retry), e o caminho legado de “Send test event” no nível do
lojista distribui um envelope webhook.test para todo endpoint
ativo independentemente do filtro. Nenhum dos dois aparece no
catálogo acima — você recebe pelo fato de ter um endpoint
registrado, não por inscrever-se.
Inscreva-se só nos eventos que você trata. Cada endpoint tem o
próprio filtro de evento; o wildcard "*" significa “todo evento,
incluindo os adicionados no futuro”. Inscrever-se em menos eventos
mantém o seu handler mais limpo e reduz a área de superfície que
a gente tem que retentar em erros.
Payload + headers
O corpo HTTP é o objeto de dados específico do evento, direto.
Sem envelope externo estilo Stripe — campos como tipo do evento, ID
da entrega e timestamp de emissão ficam nos headers. Para
payment.settled o body fica assim:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* por evento */ }
}Outros eventos carregam o próprio conjunto de campos — veja as
structs de publisher em payment-service/internal/domain/events.go
para o formato canônico até a documentação por evento chegar. Nomes
de campo são estáveis (lower snake_case); o tx hash on-chain é
sempre tx_hash (não transaction_hash).
Headers na requisição de entrada
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (só durante uma janela de tolerância de rotação)| Header | O que é |
|---|---|
X-Event | O tipo do evento (por exemplo, payment.settled). Rotear nisso na camada de proxy se você quer pular o parse do JSON. |
X-Delivery | UUID que identifica a linha de entrega. Estável em todas as retentativas do mesmo par (event, endpoint) — use como sua chave de idempotência. |
Idempotency-Key | Espelha o X-Delivery (mesmo valor). Definido em toda entrega — segue a convenção do Stripe / GitHub. |
X-Timestamp | Unix-segundos do momento em que a tentativa foi enviada. Assinado dentro do payload para que um par (body, X-Signature) capturado não possa ser replayado indefinidamente — rejeite entregas cujo timestamp esteja fora da sua janela de tolerância. |
X-Signature | sha256=<hex> de HMAC-SHA256(secret, X-Timestamp + "." + raw_body). Veja Verificação de assinatura. |
X-Signature-Prev | Mesmo algoritmo com o segredo anterior. Presente só na janela de 24 horas depois que você rotaciona — deixa verificadores rodando qualquer das duas chaves continuarem aceitando entregas durante o cutover. Depois da janela fechar o header para de ser enviado. |
Cronograma de retry
Se o seu endpoint não retornar 2xx dentro do timeout, a gente
retenta neste cronograma (timestamps relativos à primeira tentativa):
| Tentativa | Atraso | Acumulado |
|---|---|---|
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hora | 1h 21m |
| 6 | +6 horas | 7h 21m |
Depois da tentativa 6 falhar, a entrega é movida para dead letter
e o e-mail da conta do lojista é notificado. Eventos com dead-letter
podem ser replayados pelo painel Developers → Webhooks → Delivery
history do dashboard, ou diretamente via
POST /v1/webhooks/deliveries/:id/replay. Cada replay cria uma
nova linha de entrega com o próprio X-Delivery — a cadeia de
auditoria liga de volta ao original via parent_delivery_id para
que retentativas de replays não escondam o evento de origem.
Registrar um endpoint
No dashboard do lojista :
- Developers → Webhooks → + Add endpoint
- Cole a sua URL — só
https://…(HTTP puro é rejeitado; o formulário de criação também bloqueialocalhost, ranges de IP privados e URLs com userinfo) - Escolha os eventos para assinar (ou
*para todos) - Escolha o ambiente — test ou live (cada um tem o próprio segredo; eles nunca se cruzam)
- Salve → o dashboard mostra o segredo de assinatura (
whsec_…) uma vez. Guarde do lado do servidor; você vai precisar para as próximas duas features.
Você pode registrar até 10 endpoints por ambiente por lojista (por exemplo, um para entrega em produção, um para espelhamento em staging, um para um notificador no Slack). Cada um mantém o próprio estado de retry, segredo e circuit breaker por host.
Ações de ciclo de vida em cada endpoint
O menu ⋮ em cada card de endpoint expõe:
- Edit — muda a URL, descrição ou lista de inscrições. A URL
nova é re-validada com as mesmas regras
https:///SSRF da criação. - Send Test — faz POST síncrono de um envelope
webhook.test.pingassinado com o seu segredo atual. O dashboard mostra o status HTTP, latência e um snippet de 512 bytes da sua resposta. Contorna a pipeline RMQ, então a resposta é imediata. - Rotate Secret — gera um segredo novo. O anterior permanece
válido por 24 horas (entregas levam tanto
X-SignaturequantoX-Signature-Prevdurante a janela para que verificadores rodando qualquer das duas chaves continuem aceitando eventos enquanto você redeploya). - Reveal Secret — re-exibe o segredo existente. Condicionado a verificação 2FA recente e registrado no log de auditoria; use só quando você perdeu a sua cópia e o Rotate não é aceitável.
- Enable / Disable — alterna
is_activesem perder o histórico de entrega. Endpoints desabilitados ficam no dashboard mas não recebem novas entregas. - Delete — permanente. Use Disable se você pode reabilitar depois.
Dicas para handlers
- Retorne 2xx rápido. Confirme com
200 OKantes de fazer trabalho pesado — empurre a entrega para uma fila em background. O timeout por tentativa é de 10 segundos; segurar a resposta mais do que isso dispara um retry. O timeout é do lado da plataforma e não é configurável pelo lojista — contate o suporte se o seu handler genuinamente precisa de mais tempo. - Deduplique em
X-Delivery(ouIdempotency-Key— mesmo valor). Mesmo que você retorne 2xx, um proxy upstream pode derrubar a conexão e disparar um retry; o ID da entrega é estável em toda retentativa da mesma linha de entrega, então é a chave certa. - Tolere tipos de evento desconhecidos. Eventos novos podem aparecer; retorne 200 e no-op no lugar de 4xx, ou você vai encher a fila de retry.
- Logue
X-Deliveryjunto com a sua lógica de negócio. Quando algo dá errado, essa é a chave de junção entre o nosso lado e o seu.
Próximos passos
- Verificação de assinatura — o algoritmo exato + padrões de proteção contra replay.
- Conceitos → Sessões — em que estado uma sessão está quando cada evento dispara.