Image de couverture : Webhooks Stripe sur Laravel : éviter les traitements en double
PHP & Frameworks

Webhooks Stripe sur Laravel : éviter les traitements en double

1 octobre 2026
6 min de lecture
59 vues
Sébastien Muler

Votre intégration Stripe passe les tests, mais la production ne rejoue pas votre scénario idéal

Un abonnement créé après un stripe trigger, une ligne bien enregistrée en base, et on passe à la fonctionnalité suivante. Le problème, c'est que Stripe ne se comporte pas comme un appel de fonction local. Il peut renvoyer le même événement plusieurs fois, parfois pendant que votre premier traitement n'est pas terminé. Cet article, inspiré du retour d'expérience publié sur The 3 Lines of Stripe Webhook Code That Will Eventually Cost You Money, détaille le premier piège : l'idempotence, et la façon de la garantir proprement avec Laravel.

Pourquoi Stripe rejoue vos webhooks

Stripe considère une livraison comme réussie uniquement si votre endpoint répond avec un code 2xx. Dans tous les autres cas (erreur 500, timeout, réponse trop lente), l'événement est renvoyé, et ces nouvelles tentatives peuvent s'étaler sur plusieurs jours.

Imaginons un handler qui, à la réception d'un invoice.paid, appelle l'API Stripe, envoie un email de bienvenue puis met à jour plusieurs tables. Si le traitement dépasse le délai attendu, Stripe renvoie l'événement alors que le premier passage est encore en cours. Vous vous retrouvez avec deux emails, deux crédits attribués, voire deux écritures de facturation. Chaque étape prise isolément est correcte : c'est leur exécution en double qui pose problème.

Un handler idempotent produit le même résultat final, que l'événement arrive une fois ou cinq fois.

Première défense : mémoriser les événements traités

Chaque événement Stripe porte un identifiant unique (evt_...). La base du dispositif consiste à le stocker une fois le traitement terminé, et à ignorer toute livraison dont l'identifiant est déjà connu.

Une table dédiée suffit :

Schema::create('stripe_events', function (Blueprint $table) {
    $table->id();
    $table->string('stripe_id')->unique();
    $table->string('type');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});

L'index unique sur stripe_id joue un rôle de filet de sécurité : même si deux requêtes passent le contrôle applicatif en même temps, la base refusera la seconde insertion.

Deuxième défense : un verrou pour les requêtes simultanées

Vérifier qu'un identifiant n'existe pas, puis le traiter, laisse une fenêtre de course : deux requêtes concurrentes peuvent toutes deux constater que l'événement est inconnu. Le verrou atomique de Laravel ferme cette fenêtre.

public function handle(Request $request): Response
{
    $event = $this->verifyAndParse($request);

    $lock = Cache::lock('stripe-event:' . $event->id, 60);

    if (! $lock->get()) {
        // Un autre worker traite déjà cet événement : Stripe réessaiera plus tard
        return response('', 409);
    }

    try {
        if (StripeEvent::where('stripe_id', $event->id)->exists()) {
            return response('', 200);
        }

        DB::transaction(function () use ($event) {
            $this->dispatcher->process($event);

            StripeEvent::create([
                'stripe_id' => $event->id,
                'type' => $event->type,
                'processed_at' => now(),
            ]);
        });
    } finally {
        $lock->release();
    }

    return response('', 200);
}

Quelques points d'attention sur ce schéma :

  • Le verrou doit s'appuyer sur un driver de cache partagé entre tous vos serveurs (Redis par exemple). Avec un cache local à chaque machine, deux serveurs ne se verront pas.
  • La durée du verrou doit couvrir le temps de traitement le plus long attendu, sinon il expire en cours de route.
  • Répondre 409 quand le verrou est pris est un choix volontaire : Stripe considère la livraison comme échouée et la retentera, ce qui est préférable à un événement perdu.
  • L'enregistrement de l'identifiant se fait dans la même transaction que la logique métier, pour qu'un échec n'enregistre pas un événement à moitié traité.

Les effets de bord que la transaction ne protège pas

Une transaction de base de données annule des écritures, pas un email déjà parti. Pour les actions externes, deux pratiques complètent le dispositif :

  • Déclencher les emails et appels externes dans des jobs mis en file d'attente après le commit de la transaction, afin qu'un échec ne laisse pas d'effet orphelin.
  • Réduire le temps de réponse du webhook : valider la signature, enregistrer l'événement, déléguer le travail lourd à une file, puis répondre rapidement. Moins le handler est lent, moins Stripe déclenche de nouvelles tentatives inutiles.

Pensez aussi à vérifier la signature de chaque requête entrante avant tout traitement, pour ne jamais agir sur un appel qui ne vient pas de Stripe.

Tester ce que les tests heureux ne couvrent pas

Un test qui envoie un événement une fois ne prouve rien sur l'idempotence. Les scénarios à écrire sont ceux de la production :

  • Le même événement envoyé deux fois de suite : un seul effet métier attendu.
  • Deux requêtes identiques en parallèle : une seule doit aboutir, l'autre doit être refusée ou ignorée.
  • Un échec au milieu du traitement : l'identifiant ne doit pas être marqué comme traité, pour que le rejeu puisse aboutir.

En résumé

Le cas nominal d'une intégration Stripe est facile à faire fonctionner. La fiabilité se joue sur les rejeux et la concurrence : stocker les identifiants d'événements, protéger la section critique avec Cache::lock(), garder un index unique en base et sortir les effets externes de la transaction. C'est un investissement modeste face au coût d'une double facturation découverte par un client.

Ce que ça change pour vous

Un système de paiement qui fonctionne en démonstration peut malgré tout facturer deux fois un client, ou rater une information de paiement, sans que personne ne s'en aperçoive tout de suite. Les conséquences sont concrètes : des remboursements à gérer, des clients mécontents, des chiffres de revenus récurrents qui deviennent peu fiables. Vous pouvez demander à votre prestataire ou à votre responsable technique si le système sait reconnaître un message de paiement déjà reçu pour ne pas le traiter deux fois. La réponse à cette seule question en dit long sur la solidité de votre encaissement.

Partager cet article

Et votre site, il vaut quoi ?

Vitesse, accessibilité, référencement technique. Le rapport est écrit pour un dirigeant, pas pour un développeur. Gratuit, sans inscription.

Tester mon site