Webhooks

Comment sécuriser les webhooks de paiement crypto

Ce qui protège le point de terminaison d'un webhook de paiement : vérifier la signature du webhook, traitement idempotent, codes de statut exacts, nouvelles tentatives et journalisation. D'après le fonctionnement réel des webhooks Qvard.

Le webhook est le moment où votre boutique décide qu'une commande est payée. Quiconque peut appeler ce point de terminaison avec le bon corps peut tenter d'obtenir des marchandises gratuitement ; il mérite donc plus de soin que la plupart des routes. Ce guide décrit ce qui protège un webhook de paiement, d'après le fonctionnement actuel des webhooks Qvard, y compris l'en-tête X-Qvard-Signature, qui permet de vérifier qu'une notification vient bien de Qvard.

Ce qu'est un webhook Qvard, et ce qu'il n'est pas

Lorsqu'un paiement est terminé, Qvard envoie une requête POST à la callback URL enregistrée dans votre projet. Le corps est petit : l'id que vous avez fourni à la création du paiement et le status du paiement. Le webhook est envoyé lorsque le paiement est terminé, ce qui est le signal pour marquer la commande comme payée.

La requête porte un en-tête X-Qvard-Signature au format t=<unix>,v1=<hex> : un HMAC-SHA256 de l'horodatage et du corps brut, calculé avec votre clé secrète. Deux valeurs v1 sont envoyées pendant 24 heures après une rotation de clé, et l'une ou l'autre est valide. Les marchands dotés d'une clé ancienne (sans préfixe qvard_sk_) reçoivent aussi l'en-tête x-api-key sur le webhook ; vérifiez quand même la signature. Vérifiez-la sur le corps brut, avant d'analyser le JSON. Ce guide décrit la vérification de la signature telle que Qvard l'envoie aujourd'hui.

Deux propriétés de la conception déterminent tout le reste : votre point de terminaison est une URL publique, et le seul secret qui le protège est votre clé secrète, qui sert à vérifier la signature.

Les menaces qui comptent vraiment

Une liste courte est plus utile qu'une longue :

  • Une requête falsifiée. Quelqu'un qui trouve votre point de terminaison envoie { "id": "1234", "status": "completed" } pour qu'une commande soit marquée payée. La vérification de la signature l'arrête.
  • Une clé divulguée. La même clé secrète signe le webhook et authentifie vos appels à l'API. Si elle apparaît dans un dépôt, un journal ou du code front-end, les deux sont exposés.
  • Une livraison répétée. Ce n'est pas une attaque, mais c'est la cause la plus courante de dégâts réels : le même événement arrive deux fois et la commande est exécutée deux fois.
  • Un point de terminaison lent ou défaillant. Les livraisons manquées laissent des commandes payées sans exécution.
  • Une journalisation négligée. Journaliser toute la requête met la clé dans vos fichiers de journal.

Chacune des sections suivantes ferme l'une de ces menaces.

Vérifiez la clé et comparez-la en toute sécurité

Lisez l'en-tête X-Qvard-Signature à chaque requête et vérifiez-le sur le corps brut avant toute autre action. Deux détails comptent :

  • Comparaison en temps constant. Un simple === peut divulguer combien de premiers caractères correspondaient. Utilisez la fonction à temps constant de votre langage (crypto.timingSafeEqual en Node.js, hash_equals en PHP, hmac.compare_digest en Python). Vérifiez d'abord les longueurs si la fonction exige des tailles égales.
  • Rejetez en silence. Répondez 400 sans explication. Ne dites pas si la signature était absente ou erronée.

L'exemple ci-dessous montre le gestionnaire complet : vérification de la signature, petite limite de corps, validation des champs, mise à jour qui ne modifie qu'une commande pas encore payée, et réponse exactement 200. Gardez la clé dans une variable d'environnement ou un gestionnaire de secrets, jamais dans le dépôt ni dans du code 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
});

Rendez le gestionnaire idempotent

Qvard ne considère une livraison réussie que si votre point de terminaison répond exactement HTTP 200. Tout autre statut, un délai dépassé ou une redirection compte comme un échec et est réessayé : cinq tentatives au total en environ six minutes. La conséquence est que votre gestionnaire verra parfois le même événement plus d'une fois, par exemple quand votre serveur a fait le travail mais que la réponse s'est perdue.

Concevez en conséquence :

  • Rendez le changement d'état conditionnel : « marquer cette commande comme payée si elle ne l'est pas déjà », idéalement en une seule instruction de base de données, pour que deux livraisons parallèles ne gagnent pas toutes les deux.
  • Lancez l'exécution (e-mails, clés, accès) seulement lorsque la mise à jour a réellement modifié une ligne.
  • Ignorez les événements de commandes inconnues, mais répondez quand même 200 ; sinon Qvard continue de réessayer quelque chose que vous ne traiterez jamais.
  • N'agissez que sur completed. Aucun autre statut ne doit marquer une commande comme payée.

C'est aussi la défense contre une requête rejouée : un événement valide répété ne change rien la seconde fois.

Répondez vite, travaillez ensuite

Votre point de terminaison doit répondre avant que Qvard abandonne ; gardez-le donc court : vérifier, mettre à jour, mettre en file, répondre. Les parties lentes, comme l'envoi d'e-mails, l'appel d'autres systèmes et la génération de fichiers, faites-les depuis une file après la réponse.

Pensez aussi à ce que votre point de terminaison ne sait pas. Le corps ne porte que l'id et le status ; le montant attendu vient donc de votre propre commande, pas du webhook. Ne prenez jamais un montant, une devise ou un produit dans le corps de la requête.

Servez le point de terminaison uniquement en HTTPS et assurez-vous qu'aucun proxy ni redirection ne le précède : les redirections ne sont pas suivies, donc un 301 de http vers https est une livraison échouée.

Journaux, supervision et reprise

Traitez les webhooks comme des mouvements d'argent et gardez une trace.

  • Journalisez votre décision, pas le secret. Conservez l'id, le status, l'heure et votre décision (appliqué, doublon, commande inconnue, clé rejetée). Ne journalisez jamais votre clé secrète.
  • Alertez sur les rejets. Une série soudaine de réponses 400 signifie que quelqu'un sonde le point de terminaison ou que vous avez changé votre clé à un endroit et pas partout.
  • Rapprochez. Une fois par jour, comparez les commandes que vous considérez payées avec votre solde et les paiements dans l'espace Qvard.
  • Reprenez. Si votre serveur est resté arrêté plus longtemps que la fenêtre de nouvelles tentatives, renvoyez la notification de ce paiement depuis l'espace client et laissez votre gestionnaire idempotent s'en charger.

Si vous soupçonnez une fuite de la clé, remplacez-la et passez en revue lesquels de vos systèmes l'utilisaient.

Liste de contrôle avant la mise en production

  • Le point de terminaison est en HTTPS, public et joignable depuis l'extérieur de votre réseau.
  • Il vérifie l'en-tête X-Qvard-Signature sur le corps brut et répond 400 sinon.
  • Il limite la taille du corps et valide id et status.
  • Il ne marque une commande payée que sur completed et seulement si elle ne l'est pas déjà.
  • Il répond exactement 200 rapidement et fait le travail lent ensuite.
  • Il ne fait jamais confiance aux montants ni aux produits du corps.
  • Il ne journalise jamais la clé.
  • Vous avez testé une réponse en échec, une livraison en double et une commande inconnue.

Pour le flux complet autour du webhook, voir comment accepter les paiements crypto sur un site web. Les formats de requête et de réponse sont dans la documentation développeur.

FAQ

Qvard signe-t-il les webhooks avec HMAC ?

Oui. Chaque webhook porte l'en-tête X-Qvard-Signature (t=<unix>,v1=<hex>) : un HMAC-SHA256 de l'horodatage et du corps brut, calculé avec votre clé secrète. Vérifiez-le sur le corps brut et traitez votre clé secrète comme le secret.

Quel code de statut mon point de terminaison doit-il renvoyer ?

Exactement 200. Tout autre statut, un délai dépassé ou une redirection compte comme une livraison échouée et est réessayé, cinq tentatives au total en environ six minutes.

Et si mon serveur était arrêté quand le paiement s'est terminé ?

Les nouvelles tentatives couvrent environ six minutes. Ensuite, renvoyez la notification du paiement depuis l'espace client. Un gestionnaire idempotent rend cela sûr.

Puis-je me fier au montant du webhook ?

Le webhook ne porte que l'identifiant du paiement et son statut. Utilisez le montant de votre propre commande et ne la marquez payée que sur le statut completed.

Articles similaires

Articles similaires