Webhooks
Plutôt que d’interroger l’API en boucle, laissez-la vous prévenir : dès qu’un paiement atteint son issue finale, votre serveur reçoit une requête signée.
Déclarer une adresse
Dans votre espace marchand, menu API, ajoutez l’adresse HTTPS qui recevra les événements, puis choisissez lesquels : Succès est coché par défaut, Échec ne l’est pas. Cochez-le pour être prévenu des refus.
Chaque adresse a son propre secret de signature (whsec_…), généré pour vous si vous n’en fournissez pas. Le bouton Tester envoie un événement webhook.test signé : vous vérifiez votre récepteur sans créer de paiement.
Une adresse reçoit soit les paiements réels, soit les paiements de test faits avec une clé Sandbox, jamais les deux : vous le choisissez à la création. Votre serveur de production n’apprend donc jamais un paiement de test. Déclarez une seconde adresse pour vos essais.
Les événements
| Événement | Envoyé quand |
|---|---|
charge.succeeded | Un encaissement passe à SUCCEEDED. |
charge.failed | Un encaissement passe à FAILED. |
payout.succeeded | Un reversement passe à SUCCEEDED. |
payout.failed | Un reversement passe à FAILED. |
refund.succeeded | Un remboursement passe à SUCCEEDED. |
refund.failed | Un remboursement passe à FAILED. |
dispute.created | Un payeur conteste un encaissement : un litige est ouvert, avec sa date limite de réponse. |
dispute.closed | Un litige est clos : gagné (WON), perdu (LOST) ou expiré faute de réponse (EXPIRED). |
webhook.test | Vous cliquez « Tester » dans votre espace marchand. |
Seules les issues finales sont notifiées. Un paiement UNKNOWN ne déclenche rien tant qu’il n’est pas tranché : l’événement arrive quand la réconciliation a conclu.
La requête reçue
Le corps est un JSON compact, clés triées : c’est cette chaîne exacte qui est signée. data porte l’essentiel : identifiant, état, montant, devise. Pour le reste, la ventilation des frais par exemple, relisez l’opération par son id.
Chaque événement porte environment, à la racine et dans data : live pour de l’argent réel, sandbox pour un test. Vérifiez-le avant de livrer une commande, même si vos adresses sont déjà séparées.
X-Correlation-ID est celui de la requête qui a créé l’opération : il relie le webhook à votre appel d’origine dans vos journaux comme dans les nôtres.
POST /votre/adresse HTTP/1.1
Content-Type: application/json
X-Inopas-Timestamp: 1790000000
X-Inopas-Signature: sha256=c0e3a3e3ebe961a76e605e6e9155d0f0cf1c282f76de41710664a020d73ac149
X-Correlation-ID: d7f5abaa11de4f5ea00df73ee47b8b8f
{"data":{"amount_minor":2500000,"currency":"CDF","environment":"sandbox","id":"8089246d-8876-4e7b-b156-4431ae6bc527","merchant_ref":"sandbox","status":"SUCCEEDED"},"environment":"sandbox","event":"charge.succeeded"}Vérifier la signature
- Lisez le corps brut, avant tout décodage JSON. Un corps décodé puis ré-encodé ne donne plus la même signature.
- Contrôlez l’horodatage : refusez une requête de plus de 5 minutes. Un webhook intercepté ne peut pas être rejoué plus tard.
- Calculez le HMAC-SHA256 de
{horodatage}.{corps}avec le secret de l’adresse, en hexadécimal. - Comparez en temps constant avec l’en-tête, sans son préfixe
sha256=. Répondez401si elle diffère.
Les deux récepteurs ci-contre font exactement cela, sans dépendance. Ils ont été testés avec des livraisons signées par notre serveur : valide, rejouée, événement de test, mauvais secret, horodatage périmé.
// webhook.mjs : INOPAS_WEBHOOK_SECRET=whsec_… node webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.INOPAS_WEBHOOK_SECRET; // affiché dans votre espace marchand
const TOLERANCE_S = 300;
const dejaTraites = new Set(); // en production : une table de votre base
function verifierWebhook(secret, horodatage, corpsBrut, signature) {
if (!horodatage || !signature?.startsWith("sha256=")) return false;
if (Math.abs(Date.now() / 1000 - Number(horodatage)) > TOLERANCE_S) return false;
const attendu = createHmac("sha256", secret).update(`${horodatage}.${corpsBrut}`).digest("hex");
const recu = signature.slice("sha256=".length);
return recu.length === attendu.length && timingSafeEqual(Buffer.from(recu), Buffer.from(attendu));
}
createServer((req, res) => {
const morceaux = [];
req.on("data", (m) => morceaux.push(m));
req.on("end", () => {
const corps = Buffer.concat(morceaux).toString("utf8"); // BRUT, avant JSON.parse
const ts = req.headers["x-inopas-timestamp"];
if (!verifierWebhook(SECRET, ts, corps, req.headers["x-inopas-signature"])) {
res.writeHead(401).end();
return;
}
const { event, data } = JSON.parse(corps);
const cle = `${event}:${data.id}`;
if (event !== "webhook.test" && !dejaTraites.has(cle)) {
dejaTraites.add(cle);
console.log(event, data.id, data.status, data.amount_minor, data.currency);
// livrer la commande, créditer le compte client…
}
res.writeHead(200).end(); // répondre vite : passé 10 s, la livraison est relancée
});
}).listen(8080);# webhook.py : INOPAS_WEBHOOK_SECRET=whsec_… python3 webhook.py
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["INOPAS_WEBHOOK_SECRET"].encode() # affiché dans votre espace marchand
TOLERANCE_S = 300
deja_traites = set() # en production : une table de votre base
def verifier_webhook(secret, horodatage, corps_brut, signature):
if not horodatage or not signature or not signature.startswith("sha256="):
return False
try:
if abs(time.time() - int(horodatage)) > TOLERANCE_S:
return False
except ValueError:
return False
attendu = hmac.new(secret, horodatage.encode() + b"." + corps_brut, hashlib.sha256).hexdigest()
return hmac.compare_digest(attendu, signature.removeprefix("sha256="))
class Recepteur(BaseHTTPRequestHandler):
def do_POST(self):
corps = self.rfile.read(int(self.headers["Content-Length"])) # BRUT, avant json.loads
ts = self.headers.get("X-Inopas-Timestamp")
if not verifier_webhook(SECRET, ts, corps, self.headers.get("X-Inopas-Signature")):
self.send_response(401)
self.end_headers()
return
evenement = json.loads(corps)
cle = (evenement["event"], evenement["data"].get("id"))
if evenement["event"] != "webhook.test" and cle not in deja_traites:
deja_traites.add(cle)
print(evenement["event"], evenement["data"]["id"], evenement["data"]["status"])
# livrer la commande, créditer le compte client…
self.send_response(200) # répondre vite : passé 10 s, la livraison est relancée
self.end_headers()
HTTPServer(("", 8080), Recepteur).serve_forever()Vecteur de test
Avec ce secret, cet horodatage et ce corps, votre fonction de vérification doit produire exactement cette signature. Gardez ce cas dans vos tests unitaires, en neutralisant le contrôle d’horodatage : 1790000000 est une date fixe.
secret whsec_exemple_ne_pas_utiliser
horodatage 1790000000
corps {"data":{"amount_minor":2500000,"currency":"CDF","environment":"sandbox","id":"8089246d-8876-4e7b-b156-4431ae6bc527","merchant_ref":"sandbox","status":"SUCCEEDED"},"environment":"sandbox","event":"charge.succeeded"}
chaîne signée
1790000000.{"data":{"amount_minor":2500000,"currency":"CDF","environment":"sandbox","id":"8089246d-8876-4e7b-b156-4431ae6bc527","merchant_ref":"sandbox","status":"SUCCEEDED"},"environment":"sandbox","event":"charge.succeeded"}
X-Inopas-Signature attendue
sha256=c0e3a3e3ebe961a76e605e6e9155d0f0cf1c282f76de41710664a020d73ac149Répondre, et les relances
- Répondez 2xx en moins de 10 secondes. Accusez réception d’abord, traitez ensuite : une livraison sans réponse dans ce délai est considérée comme échouée.
- Une livraison échouée est relancée, avec un délai croissant plafonné à 5 minutes entre deux tentatives, pendant 72 heures. Chaque tentative porte un nouvel horodatage et donc une nouvelle signature.
- Un même événement peut arriver deux fois, par exemple si votre réponse s’est perdue. Traitez chaque couple
event+data.idune seule fois. - Ne supposez aucun ordre d’arrivée. Fiez-vous au
statusde l’événement, ou relisez l’opération. - HTTPS obligatoire. Une adresse en HTTP, ou qui pointe vers un réseau privé, est refusée.
Aucun événement n’est émis avant que l’opération soit enregistrée, et chacun l’est une seule fois de notre côté : les doublons ne viennent que des relances. Les états d’un paiement.