Aller au contenu
Sylvain Joffraud l4w
Retour

Webhook Stripe et confirmation client : billets en double

Un webhook Stripe en doublon avec la confirmation client : sur une billetterie événementielle, un paiement de N places produisait parfois 2N billets. La cause : deux chemins créaient les billets, la confirmation envoyée par l’application après le paiement et le webhook payment_intent.succeeded, chacun avec son propre test « déjà délivré ? ». Le correctif tient en une fonction de délivrance unique, sérialisée par un advisory lock PostgreSQL sur l’identifiant du PaymentIntent, sans migration de schéma.

Contexte technique : API NestJS 10.4 sur Node 22, Prisma 5.22, PostgreSQL, SDK Stripe 20, incident corrigé en septembre 2026.

Deux chemins, un paiement : d’où viennent les billets en double ?

Un PaymentIntent est l’objet Stripe qui représente un paiement, de sa création à son encaissement. Une fois le paiement réussi, deux messages arrivent à l’API, dans un ordre quelconque, parfois en même temps, parfois l’un sans l’autre :

CheminDéclencheurTest d’existence avant création
Confirmation clientPOST /payment-intents/:id/confirm depuis l’appaucun
Webhook Stripeévénement payment_intent.succeededlecture des billets du paiement

Si le webhook passait en premier, il créait N billets, puis la confirmation en créait N autres. Pire, les billets du webhook étaient incomplets : ce chemin, plus ancien, ignorait le tarif choisi et ne figeait pas le prix payé. Le résultat dépendait donc de l’ordre d’arrivée, ce qui rendait le bug intermittent et difficile à reproduire en local.

La fausse piste : envelopper le webhook dans une transaction

Quelques mois plus tôt, un premier correctif visait les livraisons dupliquées du webhook (Stripe livre ses événements « au moins une fois », voir la documentation des webhooks Stripe). Le handler avait été enveloppé dans un prisma.$transaction :

await prisma.$transaction(async (tx) => {
  const existing = await tx.ticket.findMany({ where: { paymentIntentId } });
  if (existing.length > 0) return existing;   // "idempotence"
  // ... tx.ticket.create() × quantity
});

Ce code ne protège rien. En READ COMMITTED, le niveau d’isolation par défaut de PostgreSQL, deux transactions concurrentes lisent chacune une table sans billet, puis insèrent chacune les leurs. Une transaction garantit le tout-ou-rien, pas l’exclusion mutuelle. Et la confirmation client, elle, ne faisait même pas cette vérification.

Le correctif : une seule fonction de délivrance, verrouillée par paiement

Les deux chemins appellent désormais fulfillPaymentIntent(). Le verrou est pris en premier, dans la transaction, avant la lecture :

export async function fulfillPaymentIntent(prisma: PrismaService, intent: FulfillableIntent) {
  const quantity = Number.parseInt(intent.metadata.quantity ?? '', 10);
  const lockKey = `payment:${intent.id}`;

  return prisma.$transaction(async (tx) => {
    // pg_advisory_xact_lock renvoie void, que Prisma ne sait pas désérialiser
    await tx.$queryRaw`SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(hashtext(${lockKey}))) AS lock`;

    const existing = await tx.ticket.findMany({ where: { paymentIntentId: intent.id } });
    if (existing.length > 0) {
      return { created: false, tickets: existing };
    }

    const tickets = [];
    for (let i = 0; i < quantity; i++) {
      tickets.push(await tx.ticket.create({ data: ticketDataFrom(intent) }));
    }
    return { created: true, tickets };
  });
}

Le second appel attend la fin de la transaction du premier, puis relit les billets déjà créés et les renvoie tels quels. Le mécanisme de l’advisory lock, la clé hashtext(...) et la règle « verrou et écriture dans la même transaction » sont détaillés dans l’article sur les doublons de numéros de facture et l’advisory lock PostgreSQL ; rien ne change côté Node.

Le booléen created règle aussi l’e-mail de confirmation : seul le chemin qui a créé les billets l’envoie. Le webhook utilise receipt_email s’il est renseigné, sinon l’adresse du compte ; la confirmation client arrivée en second n’envoie rien.

⚠️ Le piège Prisma : SELECT pg_advisory_xact_lock(...) passé directement à $queryRaw échoue, car la fonction renvoie void et Prisma ne sait pas désérialiser cette colonne. La sous-requête SELECT 1 AS locked FROM (...) AS lock prend le même verrou et renvoie un entier lisible.

Pourquoi pas un index unique sur le paiement ?

L’article sur les numéros de facture présente l’index unique comme la solution idéale quand il est possible. Ici, il ne l’est pas directement : un paiement produit N billets, donc payment_intent_id se répète légitimement dans la table tickets. Une contrainte UNIQUE (payment_intent_id) refuserait le deuxième billet d’une commande de deux places.

L’alternative propre serait une table fulfillments avec une ligne par paiement et une clé unique, insérée avant les billets. Elle impose une migration et un nouveau modèle ; l’advisory lock obtient la même sérialisation sans migration, avec la faiblesse connue d’un garde-fou applicatif : il ne couvre que le code qui passe par fulfillPaymentIntent().

Deux chemins qui créent la même chose, c’est deux tests d’existence qui peuvent passer en même temps : l’idempotence se construit sur un point d’entrée unique, pas sur une vérification dupliquée.

Les tests unitaires (7 cas) vérifient l’ordre des appels avec un double de Prisma qui journalise chaque opération : expect(calls.slice(0, 2)).toEqual(['lock', 'read']). Un verrou pris après la lecture passerait tous les autres tests sans rien protéger.

Lessons learned

Questions fréquentes

Pourquoi mon webhook Stripe crée-t-il des commandes en double ?
Stripe livre ses webhooks « au moins une fois » : un même événement payment_intent.succeeded peut arriver deux fois, et souvent en même temps que la confirmation envoyée par l’application cliente. Si chaque chemin vérifie « aucune commande existante » puis crée la sienne, deux requêtes concurrentes passent le test ensemble. Il faut un point de délivrance unique, sérialisé par l’identifiant du PaymentIntent.
Une transaction Prisma suffit-elle à rendre un webhook idempotent ?
Non. En READ COMMITTED, le niveau d’isolation par défaut de PostgreSQL, deux transactions qui font findMany puis create voient chacune une table vide et insèrent toutes les deux. La transaction garantit le tout-ou-rien, pas l’exclusion mutuelle. Il faut ajouter un verrou (advisory lock, SELECT … FOR UPDATE sur une ligne parente) ou une contrainte unique.
Pourquoi Prisma refuse-t-il SELECT pg_advisory_xact_lock avec $queryRaw ?
La fonction PostgreSQL pg_advisory_xact_lock renvoie void, un type que $queryRaw ne sait pas désérialiser dans son résultat. La parade consiste à l’appeler dans une sous-requête et à renvoyer une constante : SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(hashtext(…))) AS lock. Le verrou est bien pris, et Prisma reçoit un entier qu’il sait lire.
Faut-il un index unique sur payment_intent_id pour éviter les doublons de webhook Stripe ?
Seulement si un paiement produit une seule ligne. Quand un PaymentIntent donne N billets, la colonne payment_intent_id de la table des billets se répète légitimement et ne peut pas être unique. Les options sont alors une table de délivrance dédiée avec une clé unique (migration nécessaire) ou un advisory lock transactionnel sur l’identifiant du paiement (aucune migration).

Partager cet article :

Article précédent
supabase-js : « native WebSocket not found », Node 22 requis
Article suivant
Spindown impossible : le monitoring réveille vos disques