Webhooks

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.timingSafeEqual no Node.js, hash_equals no PHP, hmac.compare_digest no Python). Confira antes os tamanhos, se a função exigir tamanhos iguais.
  • Rejeite em silêncio. Responda 400 sem 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.

Node.jswebhook.js
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, o status, 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 400 significa 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-Signature sobre o corpo bruto e responde 400 caso contrário.
  • Limita o tamanho do corpo e valida id e status.
  • Marca um pedido como pago somente em completed e 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.

Artigos relacionados

Artigos relacionados