Pular para o conteúdo
KrokoPay
Voltar para o blog

Engenharia

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.

Saiba o que chega

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.

charge.paid (resumido)JSON
{  "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.

Verifique a assinatura no corpo bruto

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.parse seguido de JSON.stringify quase nunca devolve os mesmos bytes. Verifique o corpo exatamente como chegou.
  • Comparar com ===. Use timingSafeEqual, 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.
verify-signature.ts (só a biblioteca padrão do Node)TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";​const TOLERANCE_SECONDS = 300; // not fixed by the proposal yet: pick yours​export 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);  });}

Responda em milissegundos, trabalhe em segundo plano

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.

route.tsTypeScript
// 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 respondeO que isso significa para a entrega
Qualquer 2xxEntregue. O corpo da resposta é ignorado.
Qualquer outro statusFalhou. É reenviada depois, com o mesmo id de evento.
Nada antes do tempo limiteFalhou e será reenviada. O tempo limite ainda será definido.
Conexão recusada ou erro de TLSFalhou 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.

Torne cada efeito colateral idempotente

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.

worker.tsTypeScript
// 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

Quando o seu servidor cria cobranças, envie o cabeçalho Idempotency-Key para que uma requisição repetida não crie uma segunda cobrança. Veja Idempotência.

Não confie na ordem de chegada

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:

  1. 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.
  2. Descarte eventos velhos. Guarde o created_at do último evento aplicado a cada objeto e pule qualquer coisa mais antiga.
  3. Só ande para a frente. Uma cobrança sai de pending para um único desfecho (paid, failed ou expired), e só uma cobrança paga vira partially_refunded ou refunded. Escreva as transições de modo que um paid atrasado nunca desfaça um refunded.

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.

Concilie, porque algo vai escapar

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.
Consultar uma cobrança (chave de teste)bash
# 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.

Teste os casos feios de propósito

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.refunded antes de charge.paid e confira se o pedido termina reembolsado.
  • Assine com o segredo errado e confira se você responde 400.
  • Faça o endpoint responder 500 uma vez e acompanhe o reenvio chegar com o mesmo id de evento em Desenvolvedores → Webhooks.
Assinar localmente e entregar duas vezesbash
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"done

Tudo 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.

A versão curta

  1. Verifique a assinatura no corpo bruto, em tempo constante, com janela de timestamp.
  2. Guarde o evento e responda 2xx antes de fazer qualquer trabalho de verdade.
  3. Elimine duplicatas pelo id do evento e faça de cada efeito uma transição condicional.
  4. Decida pelo data.object, descarte eventos velhos e só avance os estados.
  5. Concilie os pedidos pendentes com a API de tempos em tempos.
  6. 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.

  • Produto7 min de leitura

    Anatomia de um checkout que converte

    Poucos campos, um total que não muda, uma escolha de método clara, recusas explicadas em português claro e uma tela para cada desfecho. O que uma página de pagamento precisa acertar, com o nosso próprio checkout como exemplo.

  • Produto7 min de leitura

    Como desenhar os estados de cobrança de uma assinatura

    Cobrança recorrente não cabe num booleano. É uma pequena máquina de estados, com cinco estados, alguns eventos e várias decisões de produto. Veja como modelar, o que fazer em cada estado e os erros que mais custam caro.

  • Pagamentos7 min de leitura

    Pix do lado de quem integra: checkout, estados e eventos

    Todo mundo no time já pagou com Pix. Integrar é outra história: a confirmação chega depois, o código expira e o cliente sai da sua página. Veja como desenhar esse fluxo, quais eventos tratar e como testar sem mexer em dinheiro.

Experimente no ambiente de teste

Crie uma conta para pegar suas chaves de teste, abrir o checkout hospedado e mandar um webhook assinado para você mesmo. Nada de verdade é cobrado.

As condições comerciais ainda serão anunciadas. Veja o que já está definido em preços