Webhooks sem surpresas
Reenvios, duplicatas e eventos fora de ordem não são casos raros: é isso que entrega "pelo menos uma vez" quer dizer. Como montar um consumidor que verifica, responde rápido, processa uma vez só e concilia.
Escrito por Time KrokoPayPublicado em 8 min de leitura
Webhook parece a parte fácil de uma integração de pagamentos: um endpoint HTTP que recebe um JSON. As surpresas vêm depois. Um pedido marcado como pago duas vezes. Um reembolso que chega antes do pagamento que ele estorna. Um deploy que respondeu 500 por dez minutos, sem ninguém saber quais clientes foram afetados.
Nada disso é exótico. É simplesmente o que significa entrega pelo menos uma vez: todo evento vai chegar, talvez mais de uma vez, e sem ordem garantida. Este artigo mostra como nós desenharíamos um consumidor para os webhooks da KrokoPay, seguindo a proposta de contrato da documentação. O contrato é provisório, então o formato do cabeçalho e a política de reenvio ainda podem mudar; o desenho abaixo não depende desses detalhes.
Todo evento usa o mesmo envelope. O id (evt_…) identifica o evento e é o mesmo em todas as tentativas de entrega. O type diz o que aconteceu, o created_at diz quando, e o data.object traz o objeto inteiro, como a API o devolveria naquele momento.
{ "id": "evt_6Ff7Gg8Hh9Ii0J", "object": "event", "type": "charge.paid", "api_version": "2026-11-01", "created_at": "2026-11-03T14:23:41.000Z", "data": { "object": { "id": "chg_4Fp9KzW1xQm7Rt", "object": "charge", "status": "paid", "amount": 49700, "currency": "BRL", "method": "pix" } }}O catálogo é pequeno: charge.created, charge.paid, charge.failed, charge.expired, charge.refunded, o ciclo de vida subscription.* (created, renewed, past_due, paused, resumed, canceled) e payout.requested / payout.paid. Inscreva cada endpoint só nos tipos que ele trata. Evento que não chega é evento que você não precisa ignorar do jeito certo.
Cada entrega traz o cabeçalho KrokoPay-Signature no formato t=<segundos unix>,v1=<hex>. O valor v1 é um HMAC-SHA256 de <t>.<corpo bruto>, com o segredo de assinatura do endpoint como chave (whsec_test_… no ambiente de teste). Recalcule e compare antes de confiar em qualquer campo.
- Fazer o parse antes de verificar. Um
JSON.parseseguido deJSON.stringifyquase nunca devolve os mesmos bytes. Verifique o corpo exatamente como chegou. - Comparar com
===. UsetimingSafeEqual, para que a comparação não revele quantos caracteres bateram. - Ignorar o timestamp. Sem uma janela de tolerância, uma requisição capturada pode ser repetida para sempre. A proposta ainda não fixou essa janela; escolha uma e deixe num lugar só.
- Aceitar um único
v1. Durante a troca de um segredo, o cabeçalho pode trazer mais de um. Aceite a entrega se qualquer um deles conferir.
import { createHmac, timingSafeEqual } from "node:crypto";const TOLERANCE_SECONDS = 300; // not fixed by the proposal yet: pick yoursexport function verifySignature(rawBody: string, header: string | null, secret: string): boolean { if (!header || !secret) return false; let timestamp = Number.NaN; const received: string[] = []; for (const part of header.split(",")) { const [key, value] = part.trim().split("=", 2); if (key === "t") timestamp = Number(value); if (key === "v1" && value) received.push(value); } if (!Number.isFinite(timestamp) || received.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest(); return received.some((hex) => { const signature = Buffer.from(hex, "hex"); return signature.length === expected.length && timingSafeEqual(signature, expected); });}Uma entrega é uma requisição HTTP com tempo limite. Tudo o que é lento dentro do handler, como mandar e-mail, chamar o ERP ou gerar nota, aumenta a chance de responder tarde demais. Resposta atrasada conta como falha, falha gera reenvio, e reenvio é uma duplicata que agora você precisa tratar. O handler deve fazer só três coisas: verificar, guardar e confirmar o recebimento.
// A fetch-style route: Request in, Response out.export async function POST(request: Request): Promise<Response> { const rawBody = await request.text(); // the exact bytes, before any parsing const header = request.headers.get("KrokoPay-Signature"); if (!verifySignature(rawBody, header, process.env.KROKOPAY_WEBHOOK_SECRET ?? "")) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(rawBody) as { id: string; type: string; created_at: string }; // The inbox has a unique index on event_id: a repeated delivery inserts nothing. const inserted = await db.query( "INSERT INTO webhook_inbox (event_id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING", [event.id, event.type, rawBody], ); if (inserted.rowCount === 1) await queue.push("krokopay-events", event.id); return new Response(null, { status: 200 }); // stored: acknowledge right away}| Seu endpoint responde | O que isso significa para a entrega |
|---|---|
Qualquer 2xx | Entregue. O corpo da resposta é ignorado. |
| Qualquer outro status | Falhou. É reenviada depois, com o mesmo id de evento. |
| Nada antes do tempo limite | Falhou e será reenviada. O tempo limite ainda será definido. |
| Conexão recusada ou erro de TLS | Falhou e será reenviada. |
Responda 2xx para os tipos de evento que você não trata, para que não sejam reenviados à toa. Responda 400 para assinatura inválida: a entrega aparece como falha no painel, que é exatamente onde você quer perceber um segredo mal configurado.
O índice único em event_id segura o mesmo evento entregue duas vezes. Ele não segura dois caminhos diferentes que levam ao mesmo efeito, como um webhook e uma rotina de conciliação decidindo, cada um, que o pedido foi pago. A solução fica no próprio efeito: escreva cada mudança como uma transição condicional, de modo que aplicá-la de novo não mude nada.
// Runs in the worker. Safe to run twice for the same charge.async function onChargePaid(chargeId: string, paidAt: string): Promise<void> { const result = await db.query( "UPDATE orders SET status = 'paid', paid_at = $2 WHERE charge_id = $1 AND status = 'awaiting_payment'", [chargeId, paidAt], ); // Only the run that actually changed the row sends the email. if (result.rowCount === 1) await sendReceiptEmail(chargeId);}Informação: A mesma ideia, no sentido contrário
Idempotency-Key para que uma requisição repetida não crie uma segunda cobrança. Veja Idempotência.Um charge.paid reenviado pode chegar depois do charge.refunded que veio em seguida. Uma máquina de estados que anda um passo por evento, na ordem em que eles chegam, uma hora vai fazer um pedido andar para trás. Três regras resolvem:
- Decida pelo objeto, não pelo nome do evento. O
data.object.statusé o estado no momento do evento. Trate o tipo como uma pista do que mudou. - Descarte eventos velhos. Guarde o
created_atdo último evento aplicado a cada objeto e pule qualquer coisa mais antiga. - Só ande para a frente. Uma cobrança sai de
pendingpara um único desfecho (paid,failedouexpired), e só uma cobrança paga virapartially_refundedourefunded. Escreva as transições de modo que umpaidatrasado nunca desfaça umrefunded.
Na dúvida, pergunte à fonte. Buscar a cobrança com GET /v1/charges/{id} devolve o estado atual e encerra qualquer discussão entre dois eventos.
Reenvios cobrem quedas curtas. Não cobrem um endpoint que ficou fora do ar por mais tempo que a política de reenvio, um bug que respondeu 200 e depois perdeu o job, ou uma fila apagada por engano. Parta do princípio de que um buraco vai aparecer e planeje a rotina que fecha esse buraco.
- A cada poucos minutos, selecione os pedidos que estão aguardando pagamento há mais tempo do que um comprador costuma levar.
- Para cada um, leia a cobrança na API e aplique o status pela mesma transição idempotente que o worker usa.
- Registre o que a conciliação corrigiu. Se ela corrige coisas com frequência, o bug está no consumidor, não na rotina.
# For an order still awaiting payment, ask for the charge's current state.curl https://api.krokopay.app/v1/charges/chg_4Fp9KzW1xQm7Rt \ -H "Authorization: Bearer kp_test_sk_4f9a2c7e1b8d4a6f9c3e5d7b"Filtros e paginação na listagem de cobranças ainda serão definidos na proposta. Mais um motivo para conciliar a partir dos seus próprios pedidos pendentes, e não varrendo tudo.
O caminho feliz vai funcionar de primeira. Gaste o tempo de teste nos casos acima, deliberadamente:
- Envie o mesmo payload assinado duas vezes e confira se o segundo não muda nada.
- Envie
charge.refundedantes decharge.paide confira se o pedido termina reembolsado. - Assine com o segredo errado e confira se você responde
400. - Faça o endpoint responder
500uma vez e acompanhe o reenvio chegar com o mesmoidde evento em Desenvolvedores → Webhooks.
secret="whsec_test_2e4c6a8b0d1f3a5c7e9b"payload='{"id":"evt_test_local","object":"event","type":"charge.paid","api_version":"2026-11-01","created_at":"2026-11-03T14:23:41.000Z","data":{"object":{"id":"chg_test_local","object":"charge","status":"paid"}}}'t=$(date +%s)sig=$(printf '%s.%s' "$t" "$payload" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //')# Send it twice: the second delivery must change nothing.for i in 1 2; do curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/webhooks/krokopay \ -H "Content-Type: application/json" \ -H "KrokoPay-Signature: t=$t,v1=$sig" \ -d "$payload"doneTudo aqui roda no ambiente de teste e na sua própria máquina: nenhuma requisição sai dela e nenhum dinheiro de verdade se move.
- Verifique a assinatura no corpo bruto, em tempo constante, com janela de timestamp.
- Guarde o evento e responda
2xxantes de fazer qualquer trabalho de verdade. - Elimine duplicatas pelo
iddo evento e faça de cada efeito uma transição condicional. - Decida pelo
data.object, descarte eventos velhos e só avance os estados. - Concilie os pedidos pendentes com a API de tempos em tempos.
- Teste duplicatas, inversão de ordem, assinatura inválida e falhas antes de ir para produção.
O contrato completo, com o catálogo de eventos e um handler de verificação pronto, está na documentação de webhooks. Para ver o que o comprador enxerga enquanto tudo isso acontece, leia Anatomia de um checkout que converte, ou conheça o lado de desenvolvimento do produto na página para desenvolvedores.
