📍 Lecture connexe : Stripe, Paystack, Flutterwave et Wave en 2026 : intégrer un processeur de paiement — pour la vue d’ensemble du paysage paiement.
Un client clique sur « Payer », sa connexion 4G hoquette, le serveur ne reçoit pas la réponse de l’API paiement, le client réessaie, et soudain il a payé deux fois la même commande. Cette scène se répète chaque jour dans les boutiques en ligne mal protégées. La solution s’appelle clé d’idempotence : un identifiant unique attaché à chaque opération sortante critique, qui permet au PSP de reconnaître une retentative et de renvoyer le résultat de la première tentative au lieu d’en exécuter une seconde. Ce tutoriel construit le mécanisme de bout en bout, depuis la génération de la clé jusqu’à la table d’idempotence applicative et la stratégie de retry safe côté client.
Prérequis
- Node.js 22 LTS et PostgreSQL 16+, ou stack équivalente
- Connaissance des UUID v4 et v7 (RFC 9562)
- Au moins un compte test PSP (Stripe est le plus didactique)
- Niveau attendu : intermédiaire
- Temps estimé : environ 80 minutes
Étape 1 — Comprendre le double paiement et ses causes
Trois scénarios produisent typiquement un double paiement. Premier scénario, le client clique deux fois rapidement sur le bouton « Payer » avant que la première requête n’ait abouti. Sans débounce ni clé d’idempotence, le serveur émet deux charges au PSP. Second scénario, la connexion réseau coupe entre le serveur du commerçant et l’API PSP : le serveur ne reçoit pas la réponse, le SDK retente automatiquement, mais l’opération a peut-être bel et bien réussi côté PSP et la nouvelle tentative crée une charge dupliquée. Troisième scénario, un retry manuel après échec : un opérateur d’un back-office voit une charge en statut « pending » et la relance manuellement, alors qu’elle avait abouti côté PSP mais que le webhook s’était perdu.
Dans les trois cas, la solution structurelle est d’envoyer une clé d’idempotence stable au PSP. Stripe, Paystack et Flutterwave reconnaissent cette clé : si elle a déjà été utilisée pour une opération, ils retournent le résultat de cette opération au lieu d’en créer une nouvelle. Le client commerçant reçoit donc un succès idempotent, et la base reste cohérente.
Étape 2 — Choisir le format de la clé
Une clé d’idempotence doit être unique par opération métier et stable d’une retentative à l’autre. Trois choix s’offrent. Un UUID v4 généré aléatoirement à chaque tentative — non, car il change à chaque retry. Un UUID v4 stocké en base avant l’appel et réutilisé en cas de retry — fonctionne, mais ajoute un round-trip base. Un UUID v7 (RFC 9562, mai 2024) dérivé d’un identifiant métier stable (ID de commande + intent) via un hash déterministe — meilleur compromis, ne nécessite pas de pré-allocation.
// src/idempotency.ts
import { createHash } from 'node:crypto'
export function deriveKey(orderId: string, intent: 'charge' | 'refund' | 'capture'): string {
const seed = `${orderId}:${intent}`
const hash = createHash('sha256').update(seed).digest('hex')
// Premiers 32 hex characters formattés en UUID
return `${hash.slice(0,8)}-${hash.slice(8,12)}-${hash.slice(12,16)}-${hash.slice(16,20)}-${hash.slice(20,32)}`
}
Cette dérivation a deux avantages. La clé est stable : tant que orderId et intent sont les mêmes, la clé est identique. Le retry peut donc se faire côté serveur ou côté SDK sans qu’on ait à mémoriser quoi que ce soit. La clé est unique par couple (commande, intention) : on peut faire une charge puis un refund sur la même commande sans que le PSP les confonde, car les intents sont différents.
Le format produit n’est pas un UUID v7 strict (il manque l’horodatage encodé), mais il respecte le format général d’un UUID, ce qui satisfait les validations de Stripe et des autres PSP. Pour un UUID v7 vrai, on utilise une bibliothèque comme uuidv7 sur npm, mais la version dérivée a le mérite d’être déterministe.
Étape 3 — Envoyer la clé au PSP
Stripe lit la clé d’idempotence dans le header HTTP Idempotency-Key. Toute requête qui modifie l’état (POST, certains DELETE) accepte ce header.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-11-17.acacia',
})
async function chargeOrder(orderId: string, amountCents: number) {
const key = deriveKey(orderId, 'charge')
const intent = await stripe.paymentIntents.create(
{
amount: amountCents,
currency: 'eur',
automatic_payment_methods: { enabled: true },
metadata: { order_id: orderId },
},
{ idempotencyKey: key },
)
return intent
}
Le SDK officiel Stripe accepte la clé via le second argument { idempotencyKey }, ce qui dispense d’écrire le header manuellement. Si on appelle chargeOrder deux fois consécutives avec le même orderId, Stripe renvoie deux fois le même PaymentIntent, sans en créer un deuxième et sans facturer deux fois.
La conservation de la clé chez Stripe est de 24 heures. Au-delà, le serveur considère la clé expirée et créerait une nouvelle opération si on la réutilisait. Cela n’est pas un problème en pratique : on ne retente jamais une charge légitime 24 heures plus tard sans réémettre une nouvelle commande métier (qui aura un nouvel orderId et donc une nouvelle clé).
Paystack et Flutterwave utilisent un mécanisme légèrement différent. Paystack accepte un champ reference dans le payload de POST /transaction/initialize ; si la même reference est réutilisée, Paystack retourne la transaction existante. Flutterwave fonctionne identiquement avec le champ tx_ref. Cette logique d’idempotence par référence métier est fonctionnellement équivalente au header Idempotency-Key de Stripe, juste exprimée autrement.
Étape 4 — Construire la table d’idempotence applicative
La clé d’idempotence côté PSP couvre les retries entre le serveur du commerçant et le PSP. Mais entre le client web et le serveur, on a aussi besoin d’idempotence — sinon un double-clic ou un retry navigateur peut déclencher deux logiques métier complètes (deux emails de confirmation, deux mises à jour de stock, etc.).
La table d’idempotence applicative stocke la requête entrante et son résultat, indexée par une clé fournie par le client.
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
client_key VARCHAR(64) NOT NULL,
endpoint VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status INTEGER,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours',
CONSTRAINT uniq_client_key UNIQUE (client_key, endpoint)
);
CREATE INDEX idx_idem_expires ON idempotency_keys (expires_at);
Le request_hash est calculé à partir du body de la requête entrante. Si un client réutilise la même clé avec un body différent, on retourne 422 — c’est probablement un bug client ou une tentative d’abus. Le expires_at permet à un job de purge de nettoyer les entrées anciennes.
Étape 5 — Implémenter le middleware d’idempotence
// src/middleware/idempotency.ts
import { Request, Response, NextFunction } from 'express'
import crypto from 'node:crypto'
import { Pool } from 'pg'
const pool = new Pool()
export async function idempotencyMiddleware(req: Request, res: Response, next: NextFunction) {
const key = req.headers['idempotency-key'] as string | undefined
if (!key) return res.status(400).json({ error: 'idempotency-key required' })
const reqHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex')
const existing = await pool.query(
`SELECT request_hash, response_status, response_body
FROM idempotency_keys
WHERE client_key = $1 AND endpoint = $2 AND expires_at > now()`,
[key, req.path],
)
if (existing.rowCount > 0) {
const row = existing.rows[0]
if (row.request_hash !== reqHash) {
return res.status(422).json({ error: 'key reused with different body' })
}
if (row.response_status) {
// Réponse déjà calculée : la rejouer
return res.status(row.response_status).json(row.response_body)
}
return res.status(409).json({ error: 'request in progress' })
}
// Insérer en pending
await pool.query(
`INSERT INTO idempotency_keys (client_key, endpoint, request_hash) VALUES ($1, $2, $3)`,
[key, req.path, reqHash],
)
// Wrapper res.json pour capturer la réponse finale
const originalJson = res.json.bind(res)
res.json = (body: any) => {
pool.query(
`UPDATE idempotency_keys SET response_status = $1, response_body = $2
WHERE client_key = $3 AND endpoint = $4`,
[res.statusCode, body, key, req.path],
).catch(() => {})
return originalJson(body)
}
next()
}
Ce middleware tient en quatre cas. Si la clé existe déjà avec une réponse stockée, on la rejoue : le client reçoit exactement la même réponse qu’à la première tentative, garantissant l’idempotence parfaite. Si la clé existe sans réponse (requête en cours), on retourne 409 : le client doit attendre et retenter, ce qui évite le traitement parallèle. Si la clé existe avec un body différent, on retourne 422 : c’est un usage incorrect côté client. Si la clé n’existe pas, on l’insère, on laisse le handler s’exécuter, et on capture la réponse pour la stocker.
Le wrapper sur res.json est une astuce qui permet d’intercepter la réponse sans toucher au handler. C’est plus propre qu’un appel explicite à une fonction de sauvegarde dans chaque endpoint.
Étape 6 — Générer la clé côté client
Côté navigateur, la clé d’idempotence est générée au moment où l’utilisateur clique sur « Payer ». Elle reste stable pour cette tentative d’achat, y compris en cas de retry réseau, jusqu’à ce que la transaction soit finalement confirmée ou abandonnée.
// src/client/checkout.ts
import { v4 as uuidv4 } from 'uuid'
async function pay(orderId: string, amount: number) {
// Générer ou récupérer la clé d'idempotence
let key = sessionStorage.getItem(`idem:${orderId}`)
if (!key) {
key = uuidv4()
sessionStorage.setItem(`idem:${orderId}`, key)
}
const r = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ orderId, amount }),
})
if (r.ok) {
sessionStorage.removeItem(`idem:${orderId}`) // libérer après succès
}
return r.json()
}
Le stockage en sessionStorage assure la persistance de la clé même si l’utilisateur recharge la page entre deux tentatives. À la confirmation finale du paiement, on libère la clé pour qu’une nouvelle commande puisse en générer une distincte.
Étape 7 — Tester les scénarios de retry
On vérifie l’efficacité avec trois scénarios. Premier test, le double-clic : on tape deux fois POST /api/checkout avec la même Idempotency-Key en moins d’une seconde ; on doit voir une seule charge dans le tableau de bord PSP et la deuxième requête doit retourner la même réponse 200 que la première. Second test, le retry après timeout : on coupe le réseau pendant l’appel PSP, on observe le retry du SDK ; une seule charge doit apparaître. Troisième test, le retry après crash applicatif : on tue le process Node après l’envoi à Stripe mais avant la sauvegarde en base ; au redémarrage, on retente avec la même clé ; on doit récupérer le résultat sans double facturation.
Pour simuler la coupure réseau, on utilise tc qdisc sous Linux ou Network Link Conditioner sur macOS pour introduire de la perte de paquets. Pour le crash, un simple kill -9 du process Node entre deux étapes suffit. Ces tests doivent être automatisés dans la suite d’intégration et exécutés avant chaque release majeure.
Étape 8 — Étendre aux refunds et aux payouts
L’idempotence n’est pas réservée aux charges. Les remboursements et les payouts sont tout autant exposés aux doubles déclenchements. La même clé dérivée par deriveKey(orderId, 'refund') ou deriveKey(payoutId, 'payout') garantit qu’un retry ne crée pas un deuxième remboursement.
Stripe accepte idempotencyKey sur tous ses endpoints qui modifient l’état. Paystack et Flutterwave protègent leurs refunds via la reference de la transaction d’origine — il faut donc s’appuyer sur cette référence comme clé naturelle plutôt que sur une clé d’idempotence séparée.
Étape 9 — Surveiller en production
L’observabilité dédiée à l’idempotence remonte trois métriques. Le taux de retours idempotent replay : pourcentage de requêtes pour lesquelles on a retourné une réponse cachée plutôt que d’exécuter le handler ; un taux normal est de 1 à 5 %, au-delà on enquête. Le taux de 422 (key reused with different body) : doit rester sous 0,1 % ; au-delà, c’est probablement un bug client. Et la latence du middleware : doit rester sous 5 ms au 95e percentile, sinon il devient lui-même un goulot d’étranglement.
Étape 10 — Étendre l’idempotence aux jobs asynchrones
Le mécanisme de clé d’idempotence ne se limite pas aux requêtes HTTP entrantes. Toute opération métier déclenchée par un message de queue (BullMQ, NATS, Redis Streams) bénéficie de la même protection. Un consommateur de file qui plante avant d’accuser réception du message verra ce message livré à nouveau ; sans idempotence, il déclenche deux fois la logique.
On dérive une clé à partir de l’identifiant du message et de l’identifiant de l’opération métier. Pour BullMQ, le job a un job.id qu’on utilise comme suffixe naturel de la clé. La table d’idempotence est étendue avec un champ source (http, queue, cron) pour tracer l’origine de chaque entrée et faciliter le debug.
L’effet pratique est qu’un job retraité après crash ne crée pas de doublon en base, ne déclenche pas une seconde notification email, et ne facture pas une seconde fois le client. La continuité opérationnelle est complète, et l’opérateur peut redémarrer un worker sans craindre des effets de bord.
Étape 11 — Éviter les pièges de sérialisation
Le hash du body est calculé sur la représentation JSON du payload. Or, deux JSON équivalents au sens fonctionnel peuvent avoir des hashes différents si l’ordre des clés diffère, ou si l’encodage des nombres flottants varie, ou si certains champs facultatifs ont été ajoutés.
La solution est de canonicaliser le JSON avant le hash. La RFC 8785 (JSON Canonicalization Scheme) définit un format normatif : clés triées alphabétiquement, nombres dans le format décimal le plus court, pas d’espaces inutiles. Plusieurs bibliothèques l’implémentent (canonicalize en npm, jcs en Python). On préfère canonicaliser plutôt que d’imposer un ordre de clés au client, car le client peut utiliser des frameworks (axios, fetch) qui réordonnent les clés sans qu’on en ait connaissance.
Pour les champs flottants comme les montants en devise, on convertit toujours en entier (cents/kobo) avant de hasher : un montant 5000.00 et 5000.0 et 5000 doivent produire le même hash. Cette discipline évite les 422 mystérieux qui peuvent surgir en production avec des clients qui formattent légèrement différemment.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Doubles charges malgré la clé d’idempotence | Clé régénérée à chaque retry côté client | Stocker la clé en sessionStorage jusqu’à confirmation finale |
| 422 sur retry légitime | Body sérialisé avec ordre de clés différent | Sérialiser canoniquement (clés triées) avant le hash |
| Clé réutilisée d’un client à l’autre | Clé générée côté serveur sans contexte client | La clé doit toujours être fournie par le client appelant |
| Réponse cachée stale après refund | Pas de purge des clés d’opérations annulées | Job de purge sur expires_at + invalidation explicite après refund |
| 409 récurrent sans jamais aboutir | Une requête reste en pending après crash | Timeout de pending : si pas de réponse en 60s, marquer comme failed et retourner 503 |
Étape 12 — Stratégie de purge et durée de vie
Les entrées de la table d’idempotence ne doivent pas s’accumuler indéfiniment. Une politique de rétention de 7 jours pour les opérations à faible enjeu et 30 jours pour les paiements couvre 99 % des cas légitimes de retry. Au-delà, on considère qu’un retry est plus probablement un bug ou une attaque qu’un cas normal.
Un job nocturne purge les entrées dont expires_at < now() et libère ainsi les clés. Pour les entrées particulièrement volumineuses (réponses lourdes), on peut aussi compresser le champ response_body avant stockage, ce qui réduit l’empreinte disque sans impacter la fonctionnalité.