Webhook de pagamento: sempre tratar reentrega (idempotência)


Provedores como Asaas reenviam um evento de webhook se não receberem uma resposta 200 a tempo — o que significa que o mesmo evento (ex.: “pagamento confirmado”) pode chegar mais de uma vez. Processar duas vezes sem controle pode liberar espaço em dobro, mandar e-mail duplicado, etc.

-- tabela de controle, com unique constraint no id do evento
CREATE TABLE webhook_events (
  id INT AUTO_INCREMENT PRIMARY KEY,
  provider VARCHAR(50),
  event_id VARCHAR(255) UNIQUE,
  event_type VARCHAR(100),
  payload JSON,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
// no handler: tenta inserir o evento ANTES de processar.
// se der erro de duplicado, já foi processado — responde 200 e não reprocessa.
try {
  await execute(
    'INSERT INTO webhook_events (provider, event_id, event_type, payload) VALUES (?, ?, ?, ?)',
    [provider, eventId, eventType, JSON.stringify(body)]
  );
} catch (err) {
  if (err.code === 'ER_DUP_ENTRY') return res.status(200).json({ message: 'Evento já processado' });
  throw err;
}

Outra prática importante: sempre validar um token/assinatura do provedor no header antes de processar qualquer coisa (ex. asaas-access-token), e responder 200 mesmo quando o processamento interno falha — para o provedor não entrar num loop de retentativas por um erro que já ficou registrado para investigação manual.