Fondamentaux
Idempotence
Une requête d’encaissement part, la connexion tombe, aucune réponse ne revient. Le paiement a-t-il été créé ? Avec une clé d’idempotence, la question ne se pose plus : renvoyez la même requête, vous obtenez la réponse d’origine, jamais un second encaissement.
Une clé par opération
Toute écriture (POST /v1/charges, /v1/refunds, /v1/payouts) porte un en-tête Idempotency-Key. La clé fait partie de la chaîne signée: elle ne peut pas être remplacée en chemin.
- Une clé par opération, pas par tentative. Les relances d’un même paiement réutilisent la même clé ; un nouveau paiement en prend une nouvelle.
- Dérivez-la de votre propre référence, par exemple
commande-1042-paiement-1. Après un redémarrage, votre code la recalcule à l’identique, là où un identifiant aléatoire perdu en mémoire serait introuvable. - 255 caractères au plus. Au-delà, la requête est refusée en
400avec le codevalidation_error, sans rien créer. - Les clés sont propres à votre compte : celles d’un autre marchand ne peuvent pas entrer en collision avec les vôtres.
Envoyer deux fois la même opération
KEY="ak_sandbox"
SECRET="sk_sandbox_demo"
BODY='{"amount_minor":2500000,"currency":"CDF","customer_ref":"+243810000000"}'
# Une clé par OPÉRATION, pas par tentative. Dans le Sandbox public, la clé est
# partagée entre visiteurs : on y ajoute un aléa pour ne pas croiser la leur.
IDEM="commande-1042-paiement-$(uuidgen)"
for tentative in 1 2; do
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./v1/charges.$IDEM.$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -s https://sandbox.inopas.net/v1/charges \
-H "Content-Type: application/json" \
-H "X-Inopas-Key: $KEY" \
-H "X-Inopas-Timestamp: $TS" \
-H "X-Inopas-Signature: sha256=$SIG" \
-H "Idempotency-Key: $IDEM" \
-d "$BODY"
echo
done
# Les deux réponses portent le même id : un seul encaissement existe.// rejeu.mjs : node rejeu.mjs
import { createHmac, randomUUID } from "node:crypto";
const KEY = "ak_sandbox";
const SECRET = "sk_sandbox_demo";
// Une clé par OPÉRATION, dérivée de votre propre référence : après un crash, votre
// code la recalcule à l'identique. L'aléa ne sert qu'au Sandbox public, partagé.
const commande = { id: 1042, tentativeDePaiement: 1 };
const idem = `commande-${commande.id}-paiement-${commande.tentativeDePaiement}-${randomUUID()}`;
const body = JSON.stringify({ amount_minor: 2500000, currency: "CDF", customer_ref: "+243810000000" });
async function encaisser() {
const ts = String(Math.floor(Date.now() / 1000)); // re-signé à chaque tentative
const sig = createHmac("sha256", SECRET).update(`${ts}.POST./v1/charges.${idem}.${body}`).digest("hex");
const res = await fetch("https://sandbox.inopas.net/v1/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Inopas-Key": KEY,
"X-Inopas-Timestamp": ts,
"X-Inopas-Signature": `sha256=${sig}`,
"Idempotency-Key": idem,
},
body,
});
return res.json();
}
const premiere = await encaisser();
const seconde = await encaisser(); // une relance, après une coupure par exemple
console.log(premiere.id === seconde.id); // true : un seul encaissement# rejeu.py : python3 rejeu.py
import hashlib, hmac, json, time, urllib.request, uuid
KEY = "ak_sandbox"
SECRET = b"sk_sandbox_demo"
# Une clé par OPÉRATION, dérivée de votre propre référence : après un crash, votre
# code la recalcule à l'identique. L'aléa ne sert qu'au Sandbox public, partagé.
commande = {"id": 1042, "tentative_de_paiement": 1}
idem = f"commande-{commande['id']}-paiement-{commande['tentative_de_paiement']}-{uuid.uuid4()}"
body = b'{"amount_minor":2500000,"currency":"CDF","customer_ref":"+243810000000"}'
def encaisser():
ts = str(int(time.time())) # re-signé à chaque tentative
sig = hmac.new(SECRET, f"{ts}.POST./v1/charges.{idem}.".encode() + body, hashlib.sha256).hexdigest()
req = urllib.request.Request("https://sandbox.inopas.net/v1/charges", data=body, method="POST", headers={
"Content-Type": "application/json",
"X-Inopas-Key": KEY,
"X-Inopas-Timestamp": ts,
"X-Inopas-Signature": "sha256=" + sig,
"Idempotency-Key": idem,
})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
premiere = encaisser()
seconde = encaisser() # une relance, après une coupure par exemple
print(premiere["id"] == seconde["id"]) # True : un seul encaissementCe que répond l’API
| Situation | Réponse |
|---|---|
| Première requête avec cette clé | Exécutée normalement. Une réponse réussie est enregistrée avec la clé. |
| Même clé, même requête | La réponse enregistrée, à l’identique : même statut, même corps, même id. Rien n’est créé. |
| Même clé, requête différente (méthode, chemin ou corps) | 409 idempotency_conflict. Rien n’est exécuté. |
| Même clé, première requête encore en cours | 409 idempotency_in_progress, avec Retry-After: 1. Renvoyez ensuite la même requête. |
| Requête refusée pendant son traitement | Rien n’est enregistré et la clé reste libre : corrigez la cause, puis renvoyez. |
| Écriture sans clé | 400 idempotency_key_required. |
Si une requête reste bloquée en cours, par exemple parce que le serveur qui la traitait s’est arrêté, la clé redevient utilisable au bout d’une minute.
Et ensuite
L’idempotence rend les relances sûres ; encore faut-il savoir quand relancer. Les règles de relance disent quelles erreurs se relancent et à quel rythme.