Fiabiliser les webhooks de sa passerelle de paiement : idempotence, signatures et retries
Un webhook mal fiabilisé, c'est une commande livrée deux fois, un abonnement resté actif après résiliation, ou un remboursement jamais enregistré. Les webhooks sont la source de vérité asynchrone de votre passerelle de paiement : ce guide de développeur explique comment vérifier les signatures, garantir l'idempotence, absorber les retries et ne jamais rater un événement critique.
Le rôle des webhooks : la source de vérité asynchrone
Quand un acheteur paie sur votre site, l'événement qui compte réellement ne se produit pas dans son navigateur. Il se produit sur les serveurs de votre passerelle de paiement, souvent plusieurs secondes plus tard, après que la banque émettrice a autorisé la transaction, que l'authentification forte a été validée et que le règlement a été confirmé. Le webhook est le mécanisme par lequel la passerelle vous notifie, de serveur à serveur, qu'un fait est devenu vrai. C'est une requête HTTP POST que le PSP envoie vers une URL que vous exposez (votre endpoint), avec dans le corps un objet JSON décrivant l'événement.
Contrairement à un appel d'API que vous initiez et dont vous attendez la réponse, le webhook est asynchrone et à l'initiative du PSP. Il arrive quand l'événement se produit, ce qui peut être immédiat (paiement réussi) ou différé de plusieurs jours (litige ouvert par le porteur de carte, virement SEPA confirmé, échéance d'abonnement). Les événements les plus courants qu'une passerelle vous transmet sont :
- Paiement réussi — la transaction est autorisée et capturée. C'est le signal qui doit déclencher la livraison, l'activation du service ou l'émission de la facture.
- Paiement échoué — carte refusée, solde insuffisant, authentification abandonnée. Utile pour relancer l'acheteur ou proposer un autre moyen de paiement.
- Remboursement — total ou partiel, à votre initiative ou à celle du support. Doit mettre à jour la comptabilité et l'état de la commande.
- Litige / chargeback — le porteur conteste la transaction auprès de sa banque. Vous avez un délai (souvent 7 à 21 jours selon le réseau) pour fournir des preuves.
- Cycle d'abonnement — création, renouvellement, échec de paiement récurrent, résiliation. Le pilier de tout modèle SaaS.
Ce guide s'adresse aux développeurs mais reste accessible : chaque mécanisme est expliqué avant d'être illustré. Si vous n'avez pas encore posé les fondations de votre intégration côté serveur, commencez par notre article dédié à l'intégration d'une API de paiement, puis revenez ici pour la robustesse.
Pourquoi ne jamais se fier au retour navigateur
L'erreur la plus fréquente, et la plus coûteuse, consiste à valider une commande dès que l'acheteur est redirigé vers la page de succès, ou dès que le SDK front renvoie un statut succeeded. Le navigateur n'est pas une source de vérité, pour trois raisons simples :
- Il peut disparaître. L'acheteur ferme l'onglet, perd le réseau, ou son téléphone se met en veille pendant la redirection 3-D Secure. Le paiement a réussi côté banque, mais votre page de succès n'a jamais été atteinte. Sans webhook, vous ne le sauriez jamais.
- Il peut être manipulé. L'URL de retour
/merci?status=okest publique. Un utilisateur malveillant peut l'appeler directement, sans avoir payé. Toute logique métier déclenchée par ce retour est exploitable. - Il ne connaît pas la suite. Un paiement peut réussir puis être annulé, un virement peut être rejeté après coup, un litige peut survenir des semaines plus tard. Seul le flux de webhooks vous tient informé de ces changements d'état.
Règle d'or : le retour navigateur sert uniquement à afficher un message à l'acheteur (« Merci, votre paiement est en cours de confirmation »). C'est le webhook, vérifié côté serveur, qui déclenche l'action irréversible (livraison, activation, facturation).
Le schéma d'un flux fiable ressemble à ceci :
1. L'acheteur confirme le paiement dans le navigateur.
2. La passerelle traite (autorisation banque, 3DS2, capture).
3. Le navigateur est redirigé → page "paiement en cours".
4. La passerelle envoie un webhook POST vers /webhooks/psp.
5. Votre serveur vérifie la signature, persiste l'événement, répond 200.
6. Un worker traite l'événement : livraison, email, facture.
7. La page front interroge votre API et bascule sur "confirmé".
Vérifier la signature : authentifier chaque événement
Votre endpoint est une URL publique : n'importe qui sur Internet peut lui envoyer un POST prétendant être « paiement de 5 000 € réussi ». La vérification de signature garantit que la requête provient réellement de votre passerelle et que son contenu n'a pas été altéré.
Le mécanisme repose sur un secret de endpoint, une clé partagée uniquement entre vous et le PSP (chez Stripe, une valeur commençant par whsec_, générée à la création de l'endpoint dans le tableau de bord). À chaque envoi, la passerelle calcule un HMAC (Hash-based Message Authentication Code, généralement HMAC-SHA256) de la charge utile avec ce secret, et joint le résultat dans un en-tête. Chez Stripe, c'est l'en-tête Stripe-Signature, qui contient un timestamp (t=) et une ou plusieurs signatures (v1=).
De votre côté, vous recalculez le HMAC sur exactement les mêmes données et vous comparez. Trois points sont critiques :
- Utiliser le corps brut. Le HMAC est calculé sur la charge utile exacte, octet pour octet. Si votre framework parse le JSON avant que vous ne récupériez le corps, la sérialisation peut différer (espaces, ordre des clés) et la signature ne correspondra plus. Il faut accéder au raw body.
- Comparer en temps constant. Une comparaison naïve
==peut fuir de l'information par timing. Utilisez une fonction de comparaison à temps constant (hmac.compare_digesten Python,crypto.timingSafeEqualen Node). - Vérifier la tolérance temporelle. Comparez le timestamp signé à l'heure courante et rejetez tout écart supérieur à une tolérance (Stripe recommande 5 minutes). Cela bloque les attaques par rejeu où un attaquant capture une requête valide et la renvoie plus tard.
La plupart des SDK officiels font tout cela pour vous en une ligne (stripe.webhooks.constructEvent), et c'est la voie recommandée. Voici néanmoins le principe en pseudo-code, pour comprendre ce qui se passe réellement :
signed_payload = timestamp + "." + raw_body
expected = hmac_sha256(endpoint_secret, signed_payload)
if not constant_time_equals(expected, header_signature):
return 400 # signature invalide, on rejette
if abs(now() - timestamp) > 300: # 5 minutes
return 400 # trop ancien : possible attaque par rejeu
event = json_parse(raw_body) # signature OK, on peut faire confiance
Notez qu'après un test en local, il ne faut jamais désactiver la vérification « juste pour déboguer » sur un environnement accessible depuis Internet : un endpoint sans signature est une porte ouverte à la fraude comptable. Un cas d'école consiste à recevoir un faux « paiement réussi » pour un montant élevé, à livrer la marchandise, puis à ne jamais retrouver la moindre trace de la transaction dans le tableau de bord du PSP. La perte est sèche et difficile à contester.
Un dernier détail d'implémentation piège de nombreuses équipes : lorsqu'un même endpoint reçoit des événements de plusieurs comptes ou de plusieurs environnements (test et production partageant du code), chaque source possède son propre secret. Il faut alors router la vérification vers le bon secret, sous peine de rejeter à tort des événements pourtant légitimes. Documentez précisément la correspondance entre chaque endpoint déclaré chez le PSP et le secret associé côté serveur.
L'idempotence : traiter chaque événement une seule fois
Les passerelles de paiement garantissent une livraison « au moins une fois » (at-least-once), jamais « exactement une fois ». Cette distinction, empruntée aux systèmes distribués, a une conséquence directe : un même événement peut vous parvenir deux fois, ou davantage. Le cas typique est un retry : votre serveur traite l'événement correctement mais met 12 secondes à répondre ; le PSP considère la requête comme échouée (timeout) et la renvoie. Vous recevez alors deux fois le même « paiement réussi ».
Sans protection, cela se traduit par deux commandes livrées, deux emails, deux crédits sur un portefeuille. L'idempotence consiste à concevoir votre traitement de sorte que le traiter une ou dix fois produise exactement le même résultat.
La méthode la plus robuste s'appuie sur l'identifiant unique de l'événement fourni par le PSP (chez Stripe, evt_...). Vous maintenez une table des événements déjà traités et vous refusez de retraiter un identifiant connu :
def handle(event):
# insertion atomique : échoue si l'id existe déjà
inserted = db.insert_if_absent("processed_events", event.id)
if not inserted:
return 200 # déjà traité → on acquitte sans rien refaire
with db.transaction():
process_business_logic(event) # livraison, facture, etc.
db.mark_processed(event.id)
L'insertion doit être atomique (contrainte d'unicité en base, ou INSERT ... ON CONFLICT DO NOTHING) pour résister à deux webhooks identiques traités en parallèle. Idéalement, l'enregistrement de l'identifiant et l'effet métier sont dans la même transaction : soit tout est validé, soit rien.
À ne pas confondre : la clé d'idempotence que vous envoyez lorsque vous appelez l'API du PSP (en-tête Idempotency-Key) protège vos requêtes sortantes (ne pas créer deux paiements si vous réessayez). L'idempotence des webhooks protège vos requêtes entrantes. Les deux sont nécessaires, dans les deux sens du flux.
Répondre 200 vite, traiter en asynchrone
Les PSP attendent une réponse rapide de votre endpoint, généralement sous 10 à 20 secondes. Passé ce délai, la requête est marquée comme échouée et sera réémise, ce qui déclenche des retries en cascade et, à terme, la désactivation de votre endpoint. Or, le traitement d'un événement peut être long : appel à un ERP, génération de PDF de facture, envoi d'email transactionnel, mise à jour d'un moteur de recherche.
La bonne architecture sépare la réception du traitement :
- Réception (synchrone, rapide) : vérifier la signature, contrôler l'idempotence, persister l'événement brut en base ou pousser un message dans une file d'attente, puis répondre
200immédiatement. Cette phase doit durer quelques millisecondes. - Traitement (asynchrone) : un ou plusieurs workers consomment la file (RabbitMQ, Amazon SQS, Redis/Sidekiq, Cloud Tasks…) et exécutent la logique métier, avec leurs propres retries en cas d'échec côté worker.
POST /webhooks/psp
event = verify_signature(raw_body, header) # rejette si invalide
if already_seen(event.id): return 200
queue.publish("payment-events", event) # dépôt en file
return 200 # < 50 ms
# worker, séparément
consume("payment-events"):
for event in queue:
process(event) # peut prendre plusieurs secondes
queue.ack(event) # accusé seulement si succès
Ce découplage a un double avantage : votre endpoint reste rapide et stable même sous forte charge (pic de fin de mois pour les abonnements), et un incident sur l'ERP n'entraîne plus de timeout côté PSP — les messages patientent simplement dans la file. C'est aussi la logique que l'on retrouve dans une bonne intégration de passerelle avec un CMS headless, où le front et le back communiquent de manière découplée.
Retries et backoff : ce que fait le PSP quand vous échouez
Si votre endpoint ne répond pas 2xx (timeout, erreur 500, endpoint injoignable), la passerelle considère la livraison échouée et réessaie. Chaque PSP a sa politique, mais toutes appliquent un backoff exponentiel : les tentatives s'espacent progressivement pour laisser le temps à votre système de se rétablir sans être submergé.
Stripe, par exemple, réémet un webhook en mode live pendant un maximum de 3 jours, avec des intervalles croissants (quelques minutes, puis des heures). Si toutes les tentatives échouent sur une période prolongée, l'endpoint peut être automatiquement désactivé et vous êtes notifié par email. Concrètement, cela impose deux comportements côté serveur :
- Répondre le bon code. Ne renvoyez
200que si vous avez bien accepté l'événement (au minimum persisté ou mis en file). Si vous répondez200alors que le traitement a planté sans être sauvegardé, le PSP ne réessaiera pas et l'événement est perdu pour toujours. - Rester idempotent. Puisque les retries produisent des doublons, l'idempotence de la section précédente est ce qui rend les retries inoffensifs. Retries + idempotence forment un couple indissociable.
Côté worker, appliquez aussi votre propre backoff et une dead-letter queue : après N échecs de traitement (par exemple un remboursement qu'un service tiers refuse), le message est déplacé dans une file d'erreur pour inspection manuelle, plutôt que de boucler indéfiniment.
Ne jamais présumer l'ordre d'arrivée
Les webhooks ne sont pas garantis dans l'ordre chronologique. Sur un système distribué avec plusieurs files et des retries, vous pouvez très bien recevoir payment_intent.succeeded avant payment_intent.created, ou un charge.refunded avant même d'avoir traité la confirmation initiale du paiement. Construire un état en supposant une séquence stricte mène à des bugs impossibles à reproduire.
Deux stratégies se combinent pour s'en protéger :
- Horodater et ordonner par vous-même. Chaque événement porte un timestamp de création (
created). Si vous appliquez des mises à jour d'état, ignorez un événement plus ancien que le dernier appliqué (comparaison de versions), plutôt que d'écraser aveuglément. - Récupérer l'objet à jour via l'API. Le webhook vous dit « quelque chose a changé sur cet objet ». Plutôt que de vous fier au contenu de l'événement, appelez l'API du PSP pour récupérer l'état courant de l'objet (le
PaymentIntent, l'Invoice). Vous obtenez toujours la vérité la plus récente, quel que soit l'ordre d'arrivée.
Bonne pratique : traitez le webhook comme un signal (« va regarder cet objet »), pas comme un ordre (« applique ce changement »). Cette nuance élimine la quasi-totalité des bugs liés à l'ordre et à la concurrence.
Reconstruire l'état via l'API en cas d'événement manqué
Même avec la meilleure architecture, un événement peut être définitivement perdu : endpoint en panne au-delà de la fenêtre de retry, bug qui répond 200 sans persister, migration mal gérée. Il faut donc un filet de sécurité indépendant du flux temps réel.
La réconciliation périodique consiste à interroger régulièrement l'API du PSP pour lister les objets sur une fenêtre glissante (paiements, remboursements, factures des dernières 24 ou 48 heures) et à comparer avec votre base. Tout écart révèle un événement manqué que vous rejouez :
# tâche planifiée, toutes les heures
for payment in psp.payments.list(created_gte=now() - 48h):
local = db.find_order_by_payment(payment.id)
if local is None or local.status != payment.status:
reconcile(payment) # rattrape l'événement manqué
La plupart des PSP conservent par ailleurs un journal des événements consultable dans leur tableau de bord (Stripe garde les événements 30 jours et permet de les rejouer manuellement vers un endpoint). Ce rejeu manuel est précieux pour rattraper un incident ponctuel. Cette même logique de contrôle croisé entre votre base et le PSP est le cœur d'une démarche de reporting et de réconciliation des paiements saine, qui va au-delà des seuls webhooks.
Journalisation, monitoring et alertes
On ne fiabilise que ce que l'on mesure. Un flux de webhooks doit être observable de bout en bout :
- Journaliser chaque événement reçu avec son identifiant, son type, l'horodatage de réception, le résultat de la vérification de signature et le statut de traitement. En cas d'incident, vous devez pouvoir répondre en trente secondes à « avons-nous reçu cet événement, et qu'en avons-nous fait ? ».
- Suivre des métriques : volume par type, taux d'erreurs de signature, latence de réponse de l'endpoint, profondeur de la file d'attente, âge du plus vieux message non traité, nombre de messages en dead-letter.
- Alerter sur les bons signaux : une file qui gonfle anormalement, un pic d'erreurs 500, une chute à zéro du volume reçu (l'absence d'événement est souvent plus grave qu'une erreur visible), ou l'apparition de messages en dead-letter.
Ce monitoring rejoint la surveillance plus large de votre chaîne d'encaissement : la santé des webhooks et le monitoring du taux d'autorisation se complètent pour donner une vision temps réel de la fiabilité de vos paiements.
Endpoints de test : Stripe CLI et sandbox
Un webhook ne se teste bien qu'en le déclenchant réellement. Trois outils sont incontournables :
- Le mode test / sandbox du PSP. Utilisez les clés de test et les cartes de test (chez Stripe,
4242 4242 4242 4242pour un succès, d'autres numéros pour simuler refus, litige ou 3DS2) afin de générer des vrais événements sans mouvement d'argent. - La CLI du PSP. La Stripe CLI permet de rediriger les webhooks vers votre machine locale (
stripe listen --forward-to localhost:4242/webhooks) et de déclencher un événement à la demande (stripe trigger payment_intent.succeeded). Vous testez toute votre logique sans exposer votre poste sur Internet. - Le rejeu depuis le tableau de bord. Pour reproduire un cas de production précis, rejouez l'événement exact depuis le journal des événements vers votre environnement de préproduction.
Écrivez aussi des tests automatisés qui envoient à votre endpoint une charge utile signée valide, une charge utile à signature invalide (doit renvoyer 400), et deux fois le même événement (le second ne doit produire aucun effet supplémentaire). Ces trois cas couvrent l'essentiel des régressions.
Sécurité de l'endpoint : HTTPS, IP, secret
Au-delà de la signature, quelques mesures durcissent votre endpoint :
- HTTPS obligatoire. La plupart des PSP refusent d'envoyer vers une URL non chiffrée. Le TLS protège la charge utile et l'en-tête de signature en transit.
- Secret hors du code. Le secret de endpoint (
whsec_...) est une donnée sensible : stockez-le dans un gestionnaire de secrets ou des variables d'environnement, jamais en dur dans le dépôt. Prévoyez la rotation (les PSP permettent d'avoir deux secrets valides le temps du basculement). - Restriction par IP (en complément, pas en remplacement). Certains PSP publient leurs plages d'adresses IP sortantes. Les filtrer réduit la surface d'attaque, mais ces plages évoluent : ce n'est jamais un substitut à la vérification de signature.
- URL non devinable. Un chemin d'endpoint qui ne révèle rien et n'est pas indexé limite le bruit, sans être une mesure de sécurité en soi.
Tableau : les événements clés à écouter
Écouter tous les types d'événements possibles est contre-productif (bruit, charge, doublons de logique). Concentrez-vous sur ceux qui déclenchent une action métier. Voici les plus structurants, avec les noms tels qu'employés par Stripe à titre d'exemple :
| Événement | Signification | Action typique |
|---|---|---|
payment_intent.succeeded | Paiement autorisé et capturé | Livrer, activer, facturer |
payment_intent.payment_failed | Paiement refusé ou abandonné | Relancer l'acheteur, logguer |
charge.refunded | Remboursement total ou partiel | Mettre à jour commande et compta |
charge.dispute.created | Litige / chargeback ouvert | Rassembler les preuves, alerter |
invoice.paid | Échéance d'abonnement réglée | Prolonger l'accès, envoyer la facture |
invoice.payment_failed | Échec de paiement récurrent | Dunning, avertir, suspendre à terme |
customer.subscription.deleted | Abonnement résilié | Révoquer l'accès en fin de période |
Tableau : check-list de fiabilité
Avant de considérer votre intégration comme prête pour la production, passez en revue cette check-list. Elle résume l'ensemble des mécanismes détaillés dans ce guide :
| Point de contrôle | Objectif | Fait ? |
|---|---|---|
| Vérification de signature sur le corps brut | Authenticité et intégrité | Oui / Non |
| Comparaison HMAC en temps constant | Éviter les fuites par timing | Oui / Non |
| Tolérance temporelle sur le timestamp | Anti-rejeu | Oui / Non |
| Table d'idempotence sur l'id d'événement | Traiter une seule fois | Oui / Non |
| Réponse 200 en < 1 s, traitement en file | Éviter timeouts et retries | Oui / Non |
| Backoff + dead-letter côté worker | Absorber les échecs de traitement | Oui / Non |
| Aucune hypothèse sur l'ordre d'arrivée | Robustesse à la concurrence | Oui / Non |
| Réconciliation périodique via l'API | Rattraper les événements manqués | Oui / Non |
| Journalisation + métriques + alertes | Observabilité | Oui / Non |
| HTTPS, secret externalisé, rotation prévue | Sécurité de l'endpoint | Oui / Non |
| Tests automatisés (valide, invalide, doublon) | Non-régression | Oui / Non |
Ces principes valent quel que soit votre modèle. Une marketplace qui redistribue des fonds via du split de paiement multiplie les événements à écouter (transferts, versements aux vendeurs, retenues) et rend l'idempotence et la réconciliation d'autant plus vitales : chaque doublon y a un impact financier direct sur un tiers.
Questions fréquentes
Puis-je me contenter du retour navigateur après paiement pour valider une commande ?
Non. La redirection ou la réponse côté client n'est jamais une source de vérité fiable : le navigateur peut se fermer, le réseau tomber, ou un acheteur malveillant peut rejouer l'URL de succès. Seul le webhook signé par la passerelle de paiement, confirmé côté serveur, doit déclencher la livraison ou l'activation d'un service. Le front sert uniquement à afficher un état provisoire.
Comment vérifier la signature d'un webhook Stripe ?
Stripe envoie un en-tête Stripe-Signature contenant un timestamp et une signature HMAC-SHA256 calculée sur la charge utile brute avec le secret du endpoint (whsec_...). Vous recalculez le HMAC sur timestamp.corps_brut, comparez en temps constant, et rejetez la requête si le timestamp dépasse la tolérance (5 minutes par défaut) pour bloquer les attaques par rejeu. En pratique, le SDK officiel le fait en une ligne via constructEvent.
Pourquoi mon endpoint doit-il répondre 200 très vite ?
Les PSP imposent un délai (souvent 10 à 20 secondes) au-delà duquel la requête est considérée comme échouée et sera réémise. Si votre traitement est lent (email, appel ERP, génération de facture), vous risquez des timeouts et des retries en cascade, voire la désactivation de l'endpoint. La bonne pratique : vérifier la signature, persister ou mettre en file, répondre 200 immédiatement, puis traiter en asynchrone.
Qu'est-ce que l'idempotence d'un webhook et pourquoi est-elle indispensable ?
Les passerelles garantissent une livraison « au moins une fois », jamais « exactement une fois ». Un même événement peut donc arriver deux fois (retry après timeout, incident réseau). L'idempotence consiste à mémoriser l'identifiant unique de chaque événement (evt_...) et à ignorer les doublons, pour éviter de livrer deux commandes, d'envoyer deux emails ou de créditer deux fois un compte. L'insertion de l'identifiant doit être atomique.
Que faire si un webhook est définitivement perdu ?
Deux filets de sécurité complémentaires : consulter le journal des événements dans le tableau de bord du PSP (Stripe conserve les événements 30 jours et permet de les rejouer manuellement), et surtout réconcilier périodiquement en interrogeant l'API du PSP (liste des paiements, remboursements, factures sur une fenêtre glissante) pour reconstruire l'état réel et rattraper tout événement manqué.
Faut-il se fier à l'ordre d'arrivée des webhooks ?
Non. Les événements ne sont pas garantis dans l'ordre chronologique : un succès peut arriver avant la création, un remboursement avant la confirmation. Horodatez chaque événement, ne présumez jamais de séquence, et récupérez si besoin l'objet à jour via l'API plutôt que de reconstruire l'état à partir de l'ordre reçu. Traitez le webhook comme un signal, pas comme un ordre.
Articles liés :