vendredi 25 septembre 2026

À propos

Contact

Extensions

Files d’attente et déduplication pour des webhooks entrants peu fiables

Un fournisseur de paiement qui renvoie deux fois le même événement peut faire expédier deux fois la même commande. Voici comment l'en empêcher proprement.

Par Clément Hadrot • 6 novembre 2024 • 5 min de lecture • Aucun commentaire
Files d'attente et déduplication pour des webhooks entrants peu fiables

Un client vendant des formations en ligne nous a signalé des commandes facturées deux fois à certains acheteurs, sans doublon visible côté interface de paiement. En examinant les journaux, l’extension recevait bien trois fois le même événement payment_intent.succeeded en l’espace de dix minutes, envoyé par le fournisseur de paiement lui-même, qui retente l’envoi d’un webhook tant qu’il ne reçoit pas de réponse HTTP 200 dans un délai court.

Ce comportement n’est pas un bug du fournisseur, c’est une garantie délibérée : la plupart des systèmes de webhooks appliquent une livraison « au moins une fois », jamais « exactement une fois ». Une extension qui traite chaque événement reçu comme forcément nouveau finira, tôt ou tard, par exécuter deux fois une action qui ne devrait arriver qu’une seule fois.

Le principe : l’idempotence par identifiant d’événement

La quasi-totalité des fournisseurs sérieux, qu’il s’agisse de plateformes de paiement, de services d’emailing ou de systèmes de signature électronique, incluent un identifiant unique dans chaque événement envoyé. La responsabilité de l’extension consiste à vérifier, avant tout traitement, si cet identifiant a déjà été traité, et à ignorer purement et simplement l’événement si c’est le cas.

global $wpdb;

$table = $wpdb->prefix . 'webhooks_traites';

$sql = "CREATE TABLE $table (
    event_id VARCHAR(191) NOT NULL,
    traite_le DATETIME NOT NULL,
    PRIMARY KEY (event_id)
) " . $wpdb->get_charset_collate() . ";";

La clé primaire posée directement sur event_id n’est pas un détail : c’est elle qui rend l’opération réellement atomique, même si deux requêtes du même webhook arrivent en parallèle à quelques millisecondes d’écart, ce qui arrive plus souvent qu’on ne le pense sur des files d’attente réseau instables.

Insérer avant de traiter, pas après

L’erreur la plus fréquente consiste à traiter l’événement d’abord, puis à l’enregistrer comme traité ensuite. Si le traitement échoue à mi-parcours, un nouvel essai du fournisseur retombera sur un événement jamais marqué comme traité, et recommencera tout depuis le début, parfois en double avec un traitement partiel déjà effectué. L’ordre correct est inverse : réserver l’identifiant avant de faire quoi que ce soit d’autre.

L'essentiel à retenir : Un identifiant d'événement doit être vérifié avant tout traitement ; Une table de suivi évite de rejouer un événement déjà traité ; Répondre vite puis traiter en tâche de fond réduit les nouvelles tentatives
function traiter_webhook_paiement( $event_id, $payload ) {
    global $wpdb;

    $table = $wpdb->prefix . 'webhooks_traites';

    // Insertion protégée : échoue silencieusement si l'ID existe déjà.
    $insere = $wpdb->query(
        $wpdb->prepare(
            "INSERT IGNORE INTO $table (event_id, traite_le) VALUES (%s, %s)",
            $event_id,
            current_time( 'mysql' )
        )
    );

    if ( ! $insere ) {
        return; // déjà traité, on s'arrête ici sans rien exécuter d'autre
    }

    // Le traitement métier réel n'a lieu qu'ici, jamais avant.
    marquer_commande_payee( $payload );
}

La clause INSERT IGNORE renvoie zéro ligne affectée si la clé primaire existe déjà, ce qui permet de distinguer sans ambiguïté un événement réellement nouveau d’un doublon, sans avoir besoin d’une lecture préalable suivie d’une écriture, séquence qui laisserait une fenêtre de risque entre les deux opérations.

Répondre vite, traiter ensuite

Un autre facteur aggrave les doublons : un traitement métier trop lent, qui dépasse le délai de tolérance du fournisseur avant qu’il ne considère l’envoi comme un échec et ne retente. La bonne pratique consiste à répondre par un code HTTP 200 dès que l’événement est correctement reçu et son identifiant réservé, puis à déléguer le traitement réel à une tâche différée, via wp_schedule_single_event() ou une file d’attente dédiée.

add_action( 'rest_api_init', function() {
    register_rest_route( 'paiements/v1', '/webhook', array(
        'methods'             => 'POST',
        'callback'            => 'recevoir_webhook_paiement',
        'permission_callback' => '__return_true',
    ) );
} );

function recevoir_webhook_paiement( WP_REST_Request $request ) {
    $payload  = $request->get_json_params();
    $event_id = $payload['id'] ?? '';

    if ( '' === $event_id ) {
        return new WP_REST_Response( array( 'error' => 'id manquant' ), 400 );
    }

    wp_schedule_single_event( time(), 'traiter_webhook_differe', array( $event_id, $payload ) );

    return new WP_REST_Response( array( 'received' => true ), 200 );
}

Cette séparation entre réception et traitement réduit fortement le nombre de nouvelles tentatives déclenchées par un simple ralentissement momentané du serveur, tout en gardant la déduplication en base comme filet de sécurité final, indépendant du délai de réponse.

Nettoyer une table qui grossit indéfiniment

  • Ajouter une tâche cron quotidienne qui supprime les entrées de plus de trente ou soixante jours, durée largement supérieure à la fenêtre de nouvelle tentative de la plupart des fournisseurs.
  • Indexer la colonne traite_le si la table grossit fortement, pour que cette purge reste rapide même après plusieurs mois d’activité.
  • Conserver, si le volume le permet, une trace minimale de l’événement traité plutôt que son seul identifiant, utile en cas d’investigation ultérieure sur un incident.

Un principe que nous appliquons à chaque intégration de webhook, quel que soit le fournisseur : ne jamais faire confiance à l’hypothèse qu’un événement n’arrivera qu’une seule fois, même quand la documentation du fournisseur ne mentionne pas explicitement de nouvelles tentatives.

En résumé

La déduplication des webhooks entrants ne relève pas d’une optimisation, c’est une condition de correction du système. Sans elle, toute extension qui déclenche une action irréversible, facturation, expédition, envoi d’e-mail, finira par l’exécuter plusieurs fois pour un seul événement métier réel, souvent au pire moment, sur le client qui en parlera le plus.

Partager :

À propos de l'auteur

Clément Hadrot

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi