Idempotence & rejeu

Rejouer un appel NguePay sans payer, rembourser ou virer deux fois.

Sur un réseau, une requête sans réponse ne veut rien dire. Le serveur peut ne l'avoir jamais reçue, l'avoir traitée puis perdu la réponse, ou être en train de la traiter. Votre client HTTP, lui, va réessayer — c'est son travail.

Sur une gateway de paiement, ce réessai doit être sans conséquence. Voici ce que NguePay garantit, et ce que vous devez fournir pour en bénéficier.

En un coup d'œil

OpérationCléFournie parAu rejeu
POST /v1/paymentsreferencevousrend la transaction existante (200)
POST /v1/checkout/sessionsreferencevousrend la session existante
POST /v1/payoutsreferencevousrend le virement existant (200)
POST /v1/refundsIdempotency-Keyvous, facultatifrend le remboursement existant (200)
Webhooks entrants opérateursignaturel'opérateurignoré

Le code HTTP distingue les deux cas : 201 à la création, 200 au rejeu. Le corps est identique — si vous ne regardez que le corps, vous n'avez rien à changer.

Paiements et virements : votre référence suffit

La reference que vous envoyez est la clé. Une même référence chez un même marchand ne peut produire qu'un seul paiement — et, côté virements, qu'un seul virement.

curl -X POST https://api.live.nguepay.com/api/v1/payments \
  -H "Authorization: Bearer sk_live_VOTRE_CLE" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "provider": "orange",
    "reference": "cmd_2026_00412",
    "phone": "+2250700000000"
  }'

Rejouez exactement le même appel : vous recevez la même transaction, avec le même transaction_id, et un code 200 au lieu de 201.

La garantie n'est pas une vérification, c'est une contrainte

C'est important si vous appelez depuis plusieurs processus.

NguePay commence par chercher si la référence existe. Mais entre cette lecture et l'écriture il y a une fenêtre, et deux requêtes simultanées s'y engouffrent : toutes deux lisent « rien », toutes deux créent. La vérification préalable n'est donc qu'un confort.

Ce qui garantit réellement l'unicité, c'est une contrainte d'unicité en base. Quand deux requêtes se croisent, la base tranche, et NguePay rattrape le refus pour rendre au perdant la transaction du gagnant.

En pratique : dix appels vraiment simultanés sur cmd_2026_00412 créent une transaction et renvoient dix fois le même transaction_id. Aucun ne reçoit d'erreur.

Une seule requête initie le paiement chez l'opérateur — celle qui a gagné la course. Les autres reçoivent la transaction telle qu'elle est à cet instant : son checkout_url peut donc être encore null si vous êtes arrivé pendant l'appel à l'opérateur. Relisez-la avec GET /v1/payments/{uuid} si vous avez besoin de l'URL tout de suite.

Une même référence, un autre montant : refus

Rejouer suppose redemander la même chose. Si la référence existe déjà pour un montant différent, ce n'est pas un rejeu — c'est une collision, et NguePay répond 422 NGP005 au lieu de rendre la transaction existante. La règle vaut pour POST /v1/payments, POST /v1/checkout/sessions et POST /v1/payouts.

{
  "success": false,
  "error_code": "NGP005",
  "message": "La référence « cmd_2026_00412 » est déjà utilisée pour un montant différent."
}

Ce refus protège d'une confusion coûteuse. Votre pk_ est lisible dans le source de votre boutique : n'importe qui peut s'en servir pour créer une session sur une référence de son choix. Sans ce contrôle, une session de 100 XOF posée sur cmd_2026_00412 faisait répondre « 200 » à votre demande serveur de 50 000 XOF — et vous rendait la transaction de l'attaquant.

Que faire :

Choisir une bonne référence

Remboursements : fournissez Idempotency-Key

Un remboursement n'a pas de référence naturelle. Deux remboursements partiels de 500 XOF sur la même transaction sont une demande parfaitement légitime, et NguePay n'a aucun moyen de deviner que le second est un réessai du premier. C'est donc à vous de le dire.

curl -X POST https://api.live.nguepay.com/api/v1/refunds \
  -H "Authorization: Bearer sk_live_VOTRE_CLE" \
  -H "Idempotency-Key: remb_cmd_2026_00412" \
  -H "Content-Type: application/json" \
  -d '{"transaction_uuid": "9f1c0c2e-...", "amount": 500}'

Rejouer la même clé sur la même transaction rend le remboursement déjà enregistré, sans rien renvoyer à l'opérateur : une clé donne lieu à un seul appel opérateur, même si deux requêtes arrivent en même temps.

Comme pour les références, la même clé avec un autre montant n'est pas un rejeu : elle est refusée en 422 (NGP002, « Clé d'idempotence déjà utilisée pour un remboursement d'un autre montant. »).

Si vos en-têtes ne sont pas à votre main (formulaire, plateforme no-code), le champ de corps idempotency_key fait la même chose. L'en-tête l'emporte quand les deux sont présents.

Sans clé, deux appels identiques produisent deux remboursements. Ce n'est pas un défaut : refuser le second reviendrait à interdire un remboursement partiel légitime. Fournissez une clé dès que votre client HTTP peut rejouer tout seul — c'est-à-dire presque toujours.

Le cumul ne dépasse jamais le montant

Quoi qu'il arrive, la somme des remboursements reste inférieure ou égale au montant de la transaction. Un dépassement répond 422 NGP002.

Les remboursements décidés hors NguePay — depuis la console Stripe, par exemple — comptent au même titre dès que le webhook nous les apprend. Ce point compte plus qu'il n'en a l'air : s'ils n'étaient pas décomptés, vous pouviez ensuite rembourser la totalité par l'API, et rendre l'argent deux fois.

Webhooks entrants : rien à faire

NguePay écarte les rejeux côté opérateur. Une notification captée puis renvoyée est ignorée, même si sa signature est valide et son horodatage dans la fenêtre — ce qui est précisément le cas d'un rejeu.

Le statut d'une transaction ne remonte jamais : une fois succeeded, failed, expired ou canceled, elle ne bouge plus.

Webhooks sortants : à vous de jouer

NguePay peut livrer deux fois le même événement — c'est le prix d'une livraison garantie. Rendez votre traitement idempotent, en vous appuyant sur l'en-tête X-NguePay-Delivery ou sur le couple event + transaction_id.

const cle = `${payload.event}:${payload.data.transaction_id}`;
if (await dejaTraite(cle)) return res.status(200).end();

await traiter(payload);
await marquerTraite(cle);
res.status(200).end();

Répondez 200 même sur un doublon : un 4xx ou un 5xx déclenche une nouvelle tentative, et vous verrez le même événement revenir. Détails sur la page Webhooks.

Ce qui n'est pas idempotent

Besoin d'aide ?