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 :
| Chemin | Déclencheur | Test d’existence avant création |
|---|---|---|
| Confirmation client | POST /payment-intents/:id/confirm depuis l’app | aucun |
| Webhook Stripe | événement payment_intent.succeeded | lecture 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
- Un webhook Stripe n’est jamais le seul à annoncer un paiement. La confirmation côté client, les relivraisons et les rejeux manuels déclenchent le même travail ; il faut un point de délivrance unique appelé par tous les chemins.
- Une transaction PostgreSQL ne rend pas un « lire puis créer » idempotent. En
READ COMMITTED, deux transactions concurrentes voient la même absence et insèrent toutes les deux ; il faut un verrou ou une contrainte. - L’advisory lock se prend avant la lecture, pas après. Le test qui vérifie l’ordre « verrou puis lecture » est plus utile que celui qui vérifie le résultat.
- Avec Prisma,
pg_advisory_xact_lockpasse par une sous-requête.SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(...)) AS lockcontourne levoidque$queryRawne sait pas lire. - Un index unique ne s’applique pas quand une clé porte N lignes. Un paiement de N billets exige soit une table de délivrance dédiée, soit un verrou transactionnel sur l’identifiant du paiement.
- Les effets de bord suivent la création, pas l’appel. L’e-mail part seulement depuis le chemin qui a réellement créé les billets, sinon l’acheteur en reçoit deux.