INOPAS
Fondamentaux

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énements envoyés
ÉvénementEnvoyé quand
charge.succeededUn encaissement passe à SUCCEEDED.
charge.failedUn encaissement passe à FAILED.
payout.succeededUn reversement passe à SUCCEEDED.
payout.failedUn reversement passe à FAILED.
refund.succeededUn remboursement passe à SUCCEEDED.
refund.failedUn remboursement passe à FAILED.
dispute.createdUn payeur conteste un encaissement : un litige est ouvert, avec sa date limite de réponse.
dispute.closedUn litige est clos : gagné (WON), perdu (LOST) ou expiré faute de réponse (EXPIRED).
webhook.testVous 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.

Livraison d’un webhook
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

  1. Lisez le corps brut, avant tout décodage JSON. Un corps décodé puis ré-encodé ne donne plus la même signature.
  2. Contrôlez l’horodatage : refusez une requête de plus de 5 minutes. Un webhook intercepté ne peut pas être rejoué plus tard.
  3. Calculez le HMAC-SHA256 de {horodatage}.{corps} avec le secret de l’adresse, en hexadécimal.
  4. Comparez en temps constant avec l’en-tête, sans son préfixe sha256=. Répondez 401 si 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é.

Récepteur prêt à lancer
// 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);

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.

Vecteur de test
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=c0e3a3e3ebe961a76e605e6e9155d0f0cf1c282f76de41710664a020d73ac149

Ré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.id une seule fois.
  • Ne supposez aucun ordre d’arrivée. Fiez-vous au status de 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.

Un seul branchement, tous les paiements.