charge.refunded, reçu une première fois, traité, remboursement effectué. Puis, quelques secondes plus tard, le même événement charge.refunded, portant le même identifiant, reçu une seconde fois par le même endpoint. Sur une intégration de paiement fractionné construite pour un client proposant ses formations en plusieurs échéances, cette double réception a eu une conséquence concrète : le même remboursement a été traité deux fois, entraînant l’émission de deux virements distincts vers le compte du même client.
Ce comportement n’est pas un dysfonctionnement de Stripe. La documentation officielle de Stripe l’indique explicitement : un webhook peut être délivré plusieurs fois pour un même événement, notamment en cas de délai de réponse trop long du serveur destinataire ou de problème réseau transitoire ayant empêché Stripe de recevoir l’accusé de réception attendu. C’est donc à l’intégration réceptrice de garantir qu’un même événement, reçu plusieurs fois, ne produit ses effets qu’une seule fois.
Le code qui traitait chaque réception comme un nouvel événement
Le gestionnaire de webhook de l’intégration de paiement fractionné traitait chaque événement charge.refunded reçu en déclenchant systématiquement le virement de remboursement correspondant côté système comptable interne, sans vérifier si cet événement précis avait déjà été traité auparavant :
function traiter_webhook_stripe( WP_REST_Request $request ) {
$evenement = json_decode( $request->get_body(), true );
if ( 'charge.refunded' === $evenement['type'] ) {
declencher_virement_remboursement(
$evenement['data']['object']['id'],
$evenement['data']['object']['amount_refunded']
);
}
return new WP_REST_Response( array( 'received' => true ), 200 );
}
Rien dans ce code ne distingue une première réception d’une réception répétée du même événement. Chaque appel à traiter_webhook_stripe() avec un événement charge.refunded déclenche l’action, quelle que soit l’historique des appels précédents portant sur le même identifiant d’événement.
La clé d’idempotence qui a corrigé le comportement

Chaque événement Stripe porte un identifiant unique, présent dans le champ id de la charge utile du webhook (à ne pas confondre avec l’identifiant de la charge elle-même). La correction consiste à vérifier, avant tout traitement, qu’aucune action n’a déjà été déclenchée pour cet identifiant d’événement précis :
function traiter_webhook_stripe( WP_REST_Request $request ) {
$evenement = json_decode( $request->get_body(), true );
$evenement_id = $evenement['id'];
global $wpdb;
$table = $wpdb->prefix . 'stripe_evenements_traites';
$deja_traite = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM {$table} WHERE evenement_id = %s",
$evenement_id
) );
if ( $deja_traite ) {
return new WP_REST_Response( array( 'received' => true, 'deja_traite' => true ), 200 );
}
if ( 'charge.refunded' === $evenement['type'] ) {
declencher_virement_remboursement(
$evenement['data']['object']['id'],
$evenement['data']['object']['amount_refunded']
);
}
$wpdb->insert( $table, array(
'evenement_id' => $evenement_id,
'type' => $evenement['type'],
'traite_le' => current_time( 'mysql' ),
) );
return new WP_REST_Response( array( 'received' => true ), 200 );
}
L’insertion en table ne se fait qu’une fois l’action déclenchée avec succès, ce qui garantit qu’un même identifiant d’événement Stripe ne peut plus produire deux virements de remboursement distincts, quel que soit le nombre de fois où Stripe redélivre ce même webhook.
Un point d’attention supplémentaire : la concurrence
Sur un système à fort trafic, deux réceptions quasi simultanées du même webhook pourraient, en théorie, passer toutes les deux la vérification avant que l’une des deux n’ait eu le temps d’insérer sa ligne en base. La table dédiée aux événements traités a donc été dotée d’une contrainte d’unicité sur la colonne evenement_id, transformant une éventuelle insertion concurrente en erreur de contrainte plutôt qu’en doublon silencieux, l’erreur étant alors interprétée comme un signal d’événement déjà en cours de traitement.
Étendre la vérification à tous les événements sensibles
Le même mécanisme de clé d’idempotence a été généralisé à l’ensemble des types d’événements Stripe traités par l’intégration, pas seulement charge.refunded : création de paiement, échec de paiement et annulation d’abonnement suivent désormais tous le même schéma de vérification préalable, indépendamment de la probabilité perçue de rejeu pour chaque type d’événement particulier.
Un webhook qui déclenche une action financière irréversible doit toujours être traité en supposant qu’il pourrait être reçu plusieurs fois : la question n’est pas de savoir si cela arrivera, mais quand.
Notre verdict
La documentation Stripe sur la fiabilité de livraison des webhooks est explicite sur ce point, mais reste souvent ignorée au moment de l’implémentation initiale, où l’attention se porte davantage sur le traitement fonctionnel de l’événement que sur sa possible répétition. Une clé d’idempotence, appliquée systématiquement à toute action irréversible déclenchée par un webhook entrant, referme définitivement cette classe de problèmes, quel que soit le fournisseur de paiement utilisé.