Como proteger webhooks de pagamentos em cripto
O que protege o endpoint de um webhook de pagamento: verificar a assinatura do webhook, tratamento idempotente, códigos exatos, novas tentativas e logs. Baseado em como os webhooks da Qvard realmente funcionam.

O webhook é o momento em que sua loja decide que um pedido está pago. Quem conseguir chamar esse endpoint com o corpo certo pode tentar levar mercadoria de graça, então ele merece mais cuidado do que a maioria das rotas. Este guia descreve o que protege um webhook de pagamento, com base em como os webhooks da Qvard funcionam hoje, incluindo o cabeçalho X-Qvard-Signature, que permite conferir se uma notificação veio da Qvard.
O que é um webhook da Qvard, e o que não é
Quando um pagamento é concluído, a Qvard envia uma requisição POST para a callback URL salva no seu projeto. O corpo é pequeno: o id que você informou ao criar o pagamento e o status do pagamento. O webhook é enviado quando o pagamento está concluído, que é o sinal para marcar o pedido como pago.
A requisição leva um cabeçalho X-Qvard-Signature no formato t=<unix>,v1=<hex>: um HMAC-SHA256 do timestamp e do corpo bruto, calculado com a sua chave secreta. Dois valores v1 são enviados por 24 horas após uma rotação de chave, e qualquer um dos dois é válido. Comerciantes com chave antiga (sem o prefixo qvard_sk_) também recebem o cabeçalho x-api-key no webhook; verifique a assinatura do mesmo jeito. Verifique a assinatura sobre o corpo bruto, antes de analisar o JSON. Este guia descreve a verificação da assinatura tal como a Qvard envia hoje.
Duas propriedades do desenho moldam todo o resto: seu endpoint é uma URL pública, e o único segredo que o protege é a sua chave secreta, com a qual a assinatura é verificada.
Ameaças que realmente importam
Uma lista curta é mais útil que uma longa:
- Uma requisição forjada. Alguém que encontra seu endpoint envia
{ "id": "1234", "status": "completed" }para que um pedido seja marcado como pago. A verificação da assinatura impede isso. - Uma chave vazada. A mesma chave assina o webhook e autentica suas chamadas à API. Se ela aparecer em um repositório, em um log ou em código de front-end, ambos ficam expostos.
- Uma entrega repetida. Não é um ataque, mas é a causa mais comum de dano real: o mesmo evento chega duas vezes e o pedido é cumprido duas vezes.
- Um endpoint lento ou com falhas. Entregas perdidas deixam pedidos pagos sem cumprimento.
- Logs descuidados. Registrar a requisição inteira coloca a chave nos seus arquivos de log.
Cada uma das próximas seções fecha uma dessas ameaças.
Confira a chave e compare com segurança
Leia o cabeçalho X-Qvard-Signature em cada requisição e verifique-o sobre o corpo bruto antes de fazer qualquer outra coisa. Dois detalhes importam:
- Comparação em tempo constante. Um simples
===pode vazar informação sobre quantos caracteres iniciais coincidiram. Use a função de tempo constante da sua linguagem (crypto.timingSafeEqualno Node.js,hash_equalsno PHP,hmac.compare_digestno Python). Confira antes os tamanhos, se a função exigir tamanhos iguais. - Rejeite em silêncio. Responda
400sem explicação. Não diga se a assinatura estava ausente ou errada.
O exemplo abaixo mostra o tratador completo: verificação da assinatura, um limite pequeno de corpo, validação dos campos, uma atualização que só altera um pedido ainda não pago e uma resposta exatamente 200. Guarde a chave em uma variável de ambiente ou em um gerenciador de segredos, nunca no repositório nem em código de front-end.
import express from 'express';
import { Qvard } from '@qvard/node';
const secret = process.env.QVARD_SECRET_KEY;
const qvard = new Qvard(secret);
const app = express();
app.post('/webhooks/qvard', express.raw({ type: 'application/json', limit: '10kb' }), async (req, res) => {
let event;
try {
event = qvard.webhooks.constructEvent(req.body, req.get('X-Qvard-Signature'), secret);
} catch {
return res.sendStatus(400);
}
const { id, status } = event;
if (typeof id !== 'string' || status !== 'completed') return res.sendStatus(200);
// Idempotent: the update only matches an order that is not paid yet.
const changed = await db.orders.markPaidIfNot(id);
if (changed) queue.push({ type: 'fulfil', orderId: id }); // slow work happens after the answer
return res.sendStatus(200); // exactly 200
});Torne o tratador idempotente
A Qvard considera uma entrega bem-sucedida apenas quando seu endpoint responde exatamente HTTP 200. Qualquer outro status, um timeout ou um redirecionamento conta como falha e é repetido: cinco tentativas no total em cerca de seis minutos. A consequência é que seu tratador às vezes verá o mesmo evento mais de uma vez, por exemplo quando seu servidor fez o trabalho mas a resposta se perdeu.
Projete pensando nisso:
- Torne condicional a mudança de estado: «marcar este pedido como pago se ainda não estiver pago», idealmente em uma única instrução de banco de dados, para que duas entregas paralelas não vençam ambas.
- Inicie o cumprimento (e-mails, chaves, acesso) somente quando a atualização realmente alterou uma linha.
- Ignore eventos de pedidos que você não conhece, mas ainda assim responda 200; do contrário a Qvard continua repetindo algo que você nunca vai tratar.
- Aja apenas em
completed. Nenhum outro status deve marcar um pedido como pago.
Esta também é a defesa contra uma requisição reenviada: um evento válido repetido não muda nada na segunda vez.
Responda rápido, trabalhe depois
Seu endpoint precisa responder antes que a Qvard desista, então mantenha-o curto: conferir, atualizar, enfileirar, responder. As partes lentas, como enviar e-mails, chamar outros sistemas e gerar arquivos, faça a partir de uma fila depois da resposta.
Pense também no que seu endpoint não sabe. O corpo traz apenas o id e o status, então o valor esperado vem do seu próprio pedido, não do webhook. Nunca tome um valor, uma moeda ou um produto do corpo da requisição.
Sirva o endpoint apenas por HTTPS e garanta que nenhum proxy ou redirecionamento esteja na frente: redirecionamentos não são seguidos, então um 301 de http para https é uma entrega que falhou.
Logs, monitoramento e recuperação
Trate webhooks como movimentações de dinheiro e mantenha um registro.
- Registre o que você decidiu, não o segredo. Guarde o
id, ostatus, a hora e a sua decisão (aplicado, duplicado, pedido desconhecido, chave rejeitada). Nunca registre sua chave secreta. - Alerte sobre rejeições. Uma sequência repentina de respostas
400significa que alguém está sondando o endpoint ou que você trocou a chave em um lugar e não em todos. - Concilie. Uma vez por dia, compare os pedidos que você considera pagos com o seu saldo e os pagamentos no painel da Qvard.
- Recupere. Se seu servidor ficou fora do ar por mais que a janela de novas tentativas, reenvie a notificação desse pagamento pelo painel e deixe seu tratador idempotente cuidar disso.
Se suspeitar que a chave vazou, substitua-a e revise quais dos seus sistemas a usavam.
Lista de verificação antes de entrar no ar
- O endpoint usa HTTPS, é público e acessível de fora da sua rede.
- Verifica a assinatura
X-Qvard-Signaturesobre o corpo bruto e responde400caso contrário. - Limita o tamanho do corpo e valida
idestatus. - Marca um pedido como pago somente em
completede somente se ainda não estiver pago. - Responde exatamente 200 rapidamente e faz o trabalho lento depois.
- Nunca confia em valores ou produtos do corpo.
- Nunca registra a chave.
- Você testou uma resposta com falha, uma entrega duplicada e um pedido desconhecido.
Para o fluxo completo em torno do webhook, veja como aceitar pagamentos em cripto em um site. Os formatos de requisição e resposta estão na documentação para desenvolvedores.
FAQ
A Qvard assina webhooks com HMAC?
Sim. Cada webhook leva o cabeçalho X-Qvard-Signature (t=<unix>,v1=<hex>): um HMAC-SHA256 do timestamp e do corpo bruto, calculado com a sua chave secreta. Verifique-o sobre o corpo bruto antes de analisar o JSON; o SDK faz isso com constructEvent e trate a sua chave como o segredo.
Que código de status meu endpoint deve devolver?
Exatamente 200. Qualquer outro status, um timeout ou um redirecionamento conta como entrega falha e é repetido, cinco tentativas no total em cerca de seis minutos.
E se meu servidor estava fora do ar quando o pagamento foi concluído?
As novas tentativas cobrem cerca de seis minutos. Depois disso, reenvie a notificação do pagamento pelo painel. Um tratador idempotente torna isso seguro.
Posso confiar no valor do webhook?
O webhook traz apenas o id do pagamento e seu status. Use o valor do seu próprio pedido e marque-o como pago somente no status completed.
