« payment_intent.succeeded » : ce même événement Stripe est arrivé trois fois pour une seule et même transaction, à quelques secondes d’intervalle, lors d’un pic de latence réseau entre Stripe et le serveur de la boutique. Rien d’anormal du côté de Stripe : la documentation officielle est explicite sur le sujet, un webhook peut être livré plusieurs fois pour un même événement, notamment si le serveur destinataire met trop de temps à répondre avec un code 200.
Le problème ne venait donc pas de Stripe, mais du traitement WordPress de ce webhook, qui exécutait en direct, dans le corps même de la requête HTTP entrante, l’ensemble de la logique métier : mise à jour du statut de commande, décrément du stock, envoi de l’e-mail de confirmation. Trois livraisons du même événement ont donc déclenché trois exécutions complètes de cette logique.
Ce que révélait l’historique des commandes
L’audit des commandes affectées montrait un stock décrémenté trois fois pour une commande d’un seul exemplaire d’un produit à stock limité, ainsi que trois e-mails de confirmation identiques reçus par le même client à quelques secondes d’écart. Ce comportement n’était pas systématique : il n’apparaissait que lors de pics de latence réseau, ce qui explique pourquoi il n’avait pas été détecté immédiatement en environnement de test, où la latence est généralement stable et faible.
Pourquoi un traitement synchrone est fragile face aux retries
Le gestionnaire de webhook exécutait toute la chaîne de traitement avant de renvoyer une réponse HTTP à Stripe. Si ce traitement prenait plus de temps que le délai d’attente configuré côté Stripe (par défaut quelques secondes), Stripe considère la livraison comme un échec et retente l’envoi, sans savoir que le traitement avait en réalité déjà commencé, voire abouti, côté serveur.
// Traitement synchrone initial, fragile face aux retries
add_action( 'woocommerce_stripe_webhook', function ( $event ) {
if ( 'payment_intent.succeeded' === $event->type ) {
$order = wc_get_order( $event->data->object->metadata->order_id );
$order->payment_complete();
wc_reduce_stock_levels( $order->get_id() );
// Toute cette chaîne s'exécute avant la réponse HTTP à Stripe.
}
});

La stabilisation : accuser réception immédiatement, traiter en file
La correction retenue consiste à séparer strictement deux responsabilités : accuser réception du webhook auprès de Stripe le plus vite possible, et traiter la logique métier de façon asynchrone via Action Scheduler, la bibliothèque de file de tâches déjà utilisée par WooCommerce en interne. Cette séparation permet de répondre à Stripe en quelques millisecondes, bien avant tout risque de timeout, tout en garantissant qu’une même tâche planifiée ne s’exécute qu’une seule fois grâce à un identifiant unique basé sur l’ID de l’événement Stripe :
add_action( 'woocommerce_stripe_webhook', function ( $event ) {
if ( 'payment_intent.succeeded' === $event->type ) {
$lock_key = 'stripe_event_' . $event->id;
// Verrou d'idempotence : si l'événement a déjà été planifié,
// on ignore silencieusement les livraisons suivantes.
if ( false !== get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, true, DAY_IN_SECONDS );
as_enqueue_async_action(
'traiter_paiement_stripe_confirme',
[ 'order_id' => $event->data->object->metadata->order_id ],
'stripe-webhooks'
);
}
// Réponse HTTP 200 renvoyée immédiatement, indépendamment
// du traitement métier qui suit en tâche de fond.
});
add_action( 'traiter_paiement_stripe_confirme', function ( $order_id ) {
$order = wc_get_order( $order_id );
if ( $order && ! $order->is_paid() ) {
$order->payment_complete();
wc_reduce_stock_levels( $order->get_id() );
}
});
Deux garde-fous complémentaires
- Le verrou d’idempotence basé sur l’identifiant d’événement Stripe empêche qu’un même événement soit planifié deux fois dans la file, même si les livraisons dupliquées arrivent avant l’exécution de la première tâche.
- La vérification
! $order->is_paid()à l’intérieur de la tâche elle-même constitue un second filet de sécurité, au cas où deux tâches auraient malgré tout été planifiées avant que le verrou ne prenne effet.
Un webhook n’est jamais une garantie de livraison unique. Le traiter comme tel, sans verrou d’idempotence, revient à parier sur la stabilité du réseau entre deux services que l’on ne contrôle ni l’un ni l’autre.
En résumé
Les retries de webhooks Stripe sont un comportement documenté et attendu, pas un dysfonctionnement. La fragilité venait du traitement synchrone en direct, sans protection contre les livraisons multiples. En déportant la logique métier vers une file Action Scheduler protégée par un verrou d’idempotence basé sur l’identifiant d’événement, les recalculs redondants de commande ont totalement disparu, sans qu’il ait fallu toucher à la configuration Stripe elle-même.