Idempotence
Sur un réseau, une requête peut arriver deux fois, ou sans que sa réponse vous parvienne. L'idempotence garantit qu'un même paiement n'est ni débité ni enregistré deux fois.
Webhooks reçus en double
Un même événement peut vous être livré plusieurs fois : une relance, si votre réponse s'est perdue. Enregistrez l'en-tête X-Webhook-ID de chaque événement traité, et ignorez ceux déjà vus, en répondant tout de même 200.
Quelques règles complètent ce contrôle :
- Ne vous fiez pas à l'ordre d'arrivée : fondez-vous sur
status. Une commande payée ne revient jamais en arrière. - Ne livrez que sur
SUCCEEDED, après avoir contrôléamount,currencyet votre référence (referenceoulink_metadata). - Ignorez
"test": trueen production : aucun argent n'a été encaissé.
- Python · Django
- Node.js
from decimal import Decimal
from django.db import IntegrityError, transaction
def traiter(event: dict, event_id: str):
"""Appelée après la vérification de la signature. Sans effet si l'événement est déjà traité."""
if event.get("test") and not settings.DEBUG:
return
try:
with transaction.atomic():
# Contrainte d'unicité sur event_id : deux livraisons simultanées ne passent pas toutes les deux
WebhookRecu.objects.create(event_id=event_id)
order_id = (event.get("link_metadata") or {}).get("order_id") or event.get("reference")
order = Order.objects.select_for_update().get(id=order_id)
if (
event["status"] == "SUCCEEDED"
and order.status != "paid"
and Decimal(event["amount"]) == order.total
and event["currency"] == "XOF"
):
order.status = "paid"
order.payment_id = event["payment_id"]
order.save(update_fields=["status", "payment_id"])
except IntegrityError:
pass # événement déjà traité
// Redis : SET NX garantit qu'un seul traitement réserve l'événement, même en parallèle
async function traiter(event, eventId) {
if (event.test && process.env.NODE_ENV === 'production') return;
const premier = await redis.set(`integralpay:webhook:${eventId}`, '1', { NX: true, EX: 7 * 24 * 3600 });
if (!premier) return; // déjà traité
const orderId = (event.link_metadata || {}).order_id || event.reference;
if (event.status === 'SUCCEEDED') {
await Orders.markPaidIfAmountMatches(orderId, event.amount, event.payment_id);
}
}
Si votre traitement est long (e-mails, factures), confiez-le à une file de tâches et répondez 200 tout de suite : au-delà de 10 secondes, l'envoi est considéré comme échoué et sera relancé.
Idempotency-Key des paiements directs
Pour le paiement direct, envoyez un en-tête Idempotency-Key unique par tentative de paiement (100 caractères au maximum, valable 24 heures).
| Situation | Réponse |
|---|---|
| Même clé, même contenu, paiement déjà créé | Le paiement existant, avec l'en-tête Idempotent-Replayed: true, sans nouvel appel à l'opérateur |
| Même clé, requête encore en cours de traitement | 409 idempotency_conflict : réessayez quelques secondes plus tard |
| Même clé, autre contenu (montant, numéro, moyen de paiement ou référence) | 422 idempotency_key_reused : utilisez une nouvelle clé |
Opérateur injoignable (502) | Aucun débit : renvoyez avec la même clé |
500 payment_unrecorded | Le client a peut-être été débité : ne renvoyez pas, contactez le support |
Une bonne clé combine votre identifiant de commande et un numéro de tentative : commande-1234-essai-1. Enregistrez-la avant l'appel, et réutilisez-la telle quelle pour tout renvoi de la même tentative.