Aller au contenu
CashChap CashChap Docs

Webhooks

Un webhook est une adresse HTTPS de votre serveur que CashChap appelle lorsqu'un événement se produit : paiement réussi ou échoué, retrait, remboursement, fonds retenus ou libérés. Vous choisissez, pour chaque webhook, les types d'événements qu'il reçoit. La charge est chiffrée et signée avec le secret du webhook : seul votre serveur peut la lire, et vous pouvez prouver qu'elle vient de CashChap. Si votre serveur ne répond pas, CashChap relance automatiquement l'envoi pendant plusieurs heures.

Configurer un webhook

  1. Dans le portail, ouvrez Développeurs › Webhooks dans le mode voulu (Live ou Sandbox : chaque service a ses webhooks dans chaque mode, jamais mélangés).
  2. Cliquez sur Ajouter un webhook, saisissez l'adresse https:// de votre serveur (voir les exigences sur l'adresse), un nom facultatif, puis les événements à recevoir : Tous les événements, ou des cases par groupe (Paiements, Retraits et reversements, Remboursements, Fonds).
  3. Confirmez avec votre mot de passe. Le secret de signature du webhook s'affiche alors une seule fois : copiez-le dans la configuration de votre serveur (jamais dans du code client). Perdu ? Ouvrez Modifier puis Nouveau secret : l'ancien cesse aussitôt de signer.
  4. Modifier permet de changer l'adresse, le nom, les événements et l'état (actif ou désactivé) ; la suppression est définitive. Chaque écriture demande votre mot de passe (cinq essais manqués par quart d'heure, puis blocage temporaire) et est inscrite dans le journal d'activité.
  5. Cliquez sur Tester en choisissant un type d'événement : voir les événements de test.
Page Développeurs › Webhooks en Live : compteur 2 / 5 webhooks, boutons Ajouter un webhook et Documentation, deux cartes de webhook, Site de la boutique (https://boutique-demo.example/webhooks/cashchap, Paiement réussi, Paiement échoué) et Comptabilité (https://compta.boutique-demo.example/cashchap, Retrait effectué, Retrait échoué, Remboursement versé, Remboursement refusé), avec l'état Actif, la fin du secret, la date d'ajout et les boutons Modifier, Livraisons, la liste des types de test et Tester ; le rappel des relances, puis le début de l'Historique des livraisons, coupé sous une ligne entière.
Développeurs › Webhooks : chaque webhook, ses événements et ses actions.

Ces actions sont réservées au titulaire du compte, aux administrateurs et aux développeurs ; les autres collaborateurs consultent la page sans agir. Les webhooks se gèrent aussi par l'API : /v2/webhook_endpoints.

Plusieurs webhooks, dans une limite

Un service peut avoir plusieurs webhooks par mode (par exemple un pour votre boutique, qui reçoit les paiements, et un pour votre comptabilité, qui reçoit les retraits et les remboursements), dans la limite de 5 webhooks par service et par mode. La limite compte les webhooks actifs et désactivés ; au-delà, l'ajout est refusé avec un message clair. Regroupez plutôt plusieurs types sur un même webhook ; si votre activité le justifie, le support CashChap peut relever la limite de votre compte.

Exigences sur l'adresse

Avant chaque envoi, CashChap vérifie l'adresse du webhook. Une adresse refusée n'est jamais appelée et la livraison est abandonnée ; le motif figure dans la dernière erreur de la livraison, dans l'historique du portail.

  • HTTPS obligatoire. Le portail n'enregistre qu'une adresse commençant par https://, en Live comme en Sandbox.
  • Certificat TLS valide en Live (chaîne complète, nom d'hôte correspondant, non expiré). En Sandbox, un certificat auto-signé est toléré.
  • Aucune redirection suivie : une réponse 301, 302, 307 ou 308 compte comme un échec. Indiquez directement l'adresse finale (attention aux redirections automatiques de http vers https ou de exemple.bj vers www.exemple.bj).
  • Adresses privées, locales ou réservées refusées : localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, adresses de lien local et équivalents IPv6. Le nom d'hôte est résolu avant l'envoi : s'il pointe vers une telle adresse, l'envoi est refusé. Pour tester en local, exposez votre serveur par un tunnel HTTPS public.
  • Pas d'identifiants dans l'adresse (https://utilisateur:motdepasse@…). Pour authentifier l'appel, fiez-vous à la signature.

Types d'événements

Chaque type a un code stable, en anglais, reçu dans le champ event de la charge. Liste à jour, servie aussi par l'API (GET /v2/webhook_event_types) :

Paiements

ÉvénementLibelléQuand
payment.successPaiement réussiUn encaissement (Mobile Money, carte, lien de paiement, boutique) est validé : le payeur a été débité.
payment.failedPaiement échouéUn encaissement a échoué ou a été refusé, avec la raison de l'échec.

Retraits et reversements

ÉvénementLibelléQuand
withdrawal.pendingVirement bancaire enregistréUne demande de virement bancaire est enregistrée et attend son traitement par l'équipe CashChap.
withdrawal.successRetrait effectuéUn versement automatique vers Mobile Money ou un virement bancaire est effectué.
withdrawal.failedRetrait échouéUn versement automatique ou un virement bancaire a été refusé par l'opérateur ou la banque.
withdrawal.cancelledVirement bancaire annuléUne demande de virement bancaire a été annulée avant son traitement.

Remboursements

ÉvénementLibelléQuand
refund.pendingRemboursement demandéUne demande de remboursement est enregistrée et attend la validation de CashChap.
refund.successRemboursement verséLe remboursement a été versé au payeur.
refund.rejectedRemboursement refuséLa demande de remboursement a été refusée par CashChap.
refund.failedRemboursement échouéLe versement du remboursement a échoué chez l'opérateur ; le montant revient sur votre solde.
refund.cancelledRemboursement annuléLa demande de remboursement a été annulée avant sa validation.

Fonds

ÉvénementLibelléQuand
funds.heldFonds retenusLes fonds d'un paiement validé sont retenus pour vérification : ils n'entrent pas encore dans votre solde disponible.
funds.releasedFonds libérésLes fonds retenus d'un paiement sont libérés : ils entrent dans votre solde disponible.

Choisir les événements d'un webhook (filtrage)

  • Tous les événements : le webhook reçoit tous les types, y compris ceux que CashChap ajoutera plus tard. C'est le réglage de tout webhook créé avant l'arrivée du choix des types : rien ne change pour eux.
  • Types choisis : le webhook ne reçoit que les types cochés (au moins un). Le filtre s'applique au moment où l'événement est enregistré : un type non coché n'est jamais envoyé, n'apparaît pas dans l'historique et ne peut pas être relancé. Cocher un type plus tard ne renvoie pas les événements passés.
  • Plusieurs webhooks abonnés au même type reçoivent chacun leur livraison, signée avec leur propre secret et avec leur propre identifiant X-CashChap-Delivery.
  • Un webhook désactivé ne reçoit rien, quels que soient ses types.
Formulaire Ajouter un webhook en Live : adresse de réception https://boutique-demo.example/webhooks/stock, nom Gestion des stocks, case Tous les événements décochée, puis les groupes Paiements, Retraits et reversements, Remboursements et Fonds avec une case par type (code et description) : Paiement réussi et Remboursement versé cochés ; champ mot de passe et boutons Ajouter le webhook et Annuler.
Ajouter un webhook : « Tous les événements », ou une case par type, groupe par groupe.

Les événements de test

Le bouton Tester d'un webhook actif met en file, vers ce webhook seulement, un événement du type choisi : test.ping (test simple) ou un exemple de n'importe quel type du catalogue, quel que soit l'abonnement du webhook. Il est chiffré, signé et relancé exactement comme un vrai événement et apparaît dans l'historique ; vous y lisez le code HTTP et la réponse de votre serveur.

  • test.ping : data ne contient aucune transaction : test (true), event, message, mode, service_env_uuid et sent_at.
  • Exemple d'un type (payment.success, refund.failed…) : data a la même forme que la vraie charge, avec une transaction fictive (identifiant aléatoire inconnu de CashChap) et test à true.

Répondez 2xx aux événements de test comme aux autres, mais ignorez toute charge portant test à true dans votre traitement métier : elle ne doit jamais déclencher de livraison ni de mise à jour de commande. Au plus 5 événements de test par heure et par service dans chaque mode.

La requête reçue

CashChap envoie une requête POST dont le corps est un objet JSON à un seul champ, payload, accompagné de trois en-têtes :

HTTP
POST /cashchap/webhook HTTP/1.1
Host: www.votre-site.bj
Content-Type: application/json
X-CashChap-Signature: 2d7f6c4a9e1b…(64 caractères hexadécimaux)
X-CashChap-Delivery: 7b1e2c90-5d3a-4f6e-9a8b-0c1d2e3f4a5b
X-CashChap-Attempt: 1

{"payload":"BASE64(IV sur 16 octets + chiffré)"}
En-têteContenu
X-CashChap-SignatureHMAC-SHA256(payload, secret), en hexadécimal, calculé sur la valeur exacte du champ payload (la chaîne base64), avec le secret du webhook comme clé.
X-CashChap-DeliveryIdentifiant unique de la livraison (UUID). Un même événement d'une même transaction a toujours le même identifiant, quelle que soit la tentative : c'est la clé à utiliser pour dédoublonner.
X-CashChap-AttemptNuméro de la tentative, de 1 (premier envoi) à 6. Informatif : ne vous en servez pas pour décider du traitement.

Le champ payload est le base64 de la concaténation d'un vecteur d'initialisation de 16 octets et du texte chiffré en AES-256-CBC (clé = le secret du webhook, texte chiffré lui-même encodé en base64 par OpenSSL). Le corps et la signature sont identiques à chaque tentative d'une même livraison.

Une fois déchiffré, le JSON contient event et data. Attention : data est lui-même une chaîne JSON (double encodage), à décoder une seconde fois :

Charge déchiffrée, telle que reçue
{"event":"payment.success","data":"{\"status\":\"VALIDATED\",\"transactionId\":\"2f1c3b6e-9a0d-4c1e-8f5a-2b7d9e4c6a10\",\"payment\":{\"provider\":\"mtn-benin\",…},\"mode\":\"LIVE\"}"}
Contenu de data après le second décodage (paiement validé)
{
    "status": "VALIDATED",
    "transactionId": "2f1c3b6e-9a0d-4c1e-8f5a-2b7d9e4c6a10",
    "payment": {
      "provider": "mtn-benin",
      "method": "MOBILE MONEY",
      "network": "MTN",
      "request": { "amount": 2500, "currency": "XOF" },
      "gateway": { "amount": 2500, "currency": "XOF" },
      "debited_amount": 2500,
      "commission_fees": 43,
      "available_amount": 2457,
      "is_available": true
    },
    "currency": { "from": "XOF", "to": "XOF", "rate": 1 },
    "customer": { "name": "Koffi Mensah", "email": "koffi.mensah@example.com", "phone": "2290197000000" },
    "externalId": "c0a8012e-1f2b-4d3c-9e8f-7a6b5c4d3e2f",
    "metadata": { "commande": "A-2026-00417" },
    "message": null,
    "transaction_created_at": "10-10-2026 09:14:02",
    "completed_at": "10-10-2026 09:14:21",
    "mode": "LIVE"
  }
}

Pour les fonds retenus ou libérés (funds.held, funds.released), data contient transactionId (le paiement concerné), status, funds (state : held ou released, held_at, released_at ; reason = collection_confirmation pour un encaissement à confirmer), amount, currency, available_amount, externalId, message et mode.

Pour un remboursement (refund.*), data contient transactionId (identifiant du remboursement), paymentId (paiement remboursé), amount, currency, status, reason, origin et mode. Pour un retrait, data contient le statut, l'identifiant, le montant, la passerelle, la référence du virement le cas échéant et mode.

Livraison et relances

Chaque événement est d'abord enregistré dans une boîte d'envoi, puis livré à votre adresse. La livraison est réussie dès que votre serveur répond par un code HTTP 2xx dans les 10 secondes. Toute autre issue compte comme un échec : délai dépassé, connexion impossible, redirection, code 3xx, 4xx ou 5xx.

Après un échec, CashChap relance automatiquement l'envoi, avec un délai croissant :

TentativeEnvoyée
1dès l'événement
21 minute après l'échec de la 1re
35 minutes après l'échec de la 2e
430 minutes après l'échec de la 3e
52 heures après l'échec de la 4e
66 heures après l'échec de la 5e

Soit 6 tentatives au total, réparties sur un peu plus de huit heures et demie. Après la sixième tentative manquée, la livraison est abandonnée : l'événement ne sera plus envoyé automatiquement. Son état, le code HTTP de votre dernière réponse et le motif du dernier échec restent consultables dans l'historique des livraisons du portail, d'où vous pouvez la relancer à la main.

  • Les relances cessent dès qu'une tentative réussit : une livraison réussie n'est jamais renvoyée.
  • Un webhook désactivé ou supprimé, ou une adresse refusée (voir les exigences), met fin aux relances sans attendre les six tentatives.
  • Un même événement d'une même transaction ne donne lieu qu'à une seule livraison par webhook (un seul X-CashChap-Delivery par webhook). Vous pouvez néanmoins recevoir plusieurs fois la même requête, par exemple si votre serveur a traité la demande mais répondu trop tard : d'où l'importance du dédoublonnage.
  • Les relances ne garantissent pas l'ordre d'arrivée : un payment.success relancé peut parvenir après un événement plus récent d'une autre transaction.

Si une livraison a été abandonnée, la transaction reste lisible à tout moment par GET /v2/transactions/{id} : gardez une tâche de fond qui interroge les transactions encore PENDING chez vous.

Historique des livraisons dans le portail

La page Développeurs › Webhooks liste, pour l'environnement affiché, chaque événement envoyé à votre serveur, du plus récent au plus ancien, vingt par page, avec un filtre par statut, par webhook et par référence de transaction (uuid). Chaque ligne donne la date, l'événement, le webhook destinataire, la transaction, la tentative (n sur 6), le code HTTP de votre dernière réponse et le statut :

StatutSignification
LivréeVotre serveur a répondu 2xx : rien à renvoyer.
En attenteTentative en cours ou planifiée (le prochain essai est indiqué).
ÉchouéeLa dernière tentative a échoué ; une relance automatique est planifiée.
AbandonnéeSix tentatives manquées, ou arrêt des relances (webhook désactivé ou supprimé, adresse refusée) : plus aucun envoi automatique.

Le détail d'une livraison ajoute l'adresse appelée, la date de livraison, la durée de l'appel, la dernière erreur, un extrait de la réponse de votre serveur (500 caractères au plus) et les données envoyées (champ data, données sensibles masquées). Le bouton Relancer remet en file une livraison échouée ou abandonnée, ou en attente depuis plus de dix minutes sans nouvelle ; une livraison déjà reçue ou en cours de traitement n'est pas relançable. Au plus 10 relances par heure pour une même livraison. La relance repart avec le même X-CashChap-Delivery : votre dédoublonnage s'applique.

Si le gateway est momentanément injoignable, la page signale l'historique indisponible et le reste des réglages continue de fonctionner.

Bloc Historique des livraisons en Live : filtres Statut, Webhook et Référence de transaction, bouton Filtrer, puis les livraisons les plus récentes avec la date, l'événement (payment.success, payment.failed, withdrawal.success, refund.success), le webhook (Site de la boutique ou Comptabilité), la transaction, la tentative sur 6, le code HTTP et le statut : toutes Livrée, dont celle du 9 oct. à la 3e tentative, sauf celle du 4 oct., Abandonnée après 6 tentatives (code 500).
L'historique des livraisons : tentatives, code HTTP et statut de chaque envoi.

Vérifier et déchiffrer

Les deux exemples ci-dessous vérifient la signature, déchiffrent la charge, répondent 2xx immédiatement et dédoublonnent par X-CashChap-Delivery avant de traiter.

PHP
<?php
// Point d'entrée du webhook CashChap (PHP 8). Le secret vient de la configuration du serveur.
$secret = getenv('CASHCHAP_WEBHOOK_SECRET');
$corps = json_decode((string) file_get_contents('php://input'), true);
$payload = is_array($corps) ? (string) ($corps['payload'] ?? '') : '';
$signature = $_SERVER['HTTP_X_CASHCHAP_SIGNATURE'] ?? '';
$livraison = $_SERVER['HTTP_X_CASHCHAP_DELIVERY'] ?? '';

// 1. Signature : HMAC-SHA256 du champ payload, comparaison en temps constant.
if ($payload === '' || !hash_equals(hash_hmac('sha256', $payload, $secret), $signature)) {
    http_response_code(403);
    exit;
}

// 2. Déchiffrement : 16 octets d'IV puis le texte chiffré (base64) en AES-256-CBC.
$binaire = base64_decode($payload, true);
$iv = substr($binaire, 0, 16);
$chiffre = substr($binaire, 16);
$json = openssl_decrypt($chiffre, 'AES-256-CBC', $secret, 0, $iv);
$message = $json !== false ? json_decode($json, true) : null;
if (!is_array($message) || empty($message['event']) || empty($message['data'])) {
    http_response_code(400);
    exit;
}

// data est une chaîne JSON (double encodage) : second décodage.
$donnees = is_string($message['data']) ? json_decode($message['data'], true) : $message['data'];

// 3. Dédoublonnage : une livraison déjà vue est acquittée sans retraitement. La clé est l'identifiant de livraison
//    (identique à chaque tentative) ou, à défaut, transactionId + événement. L'insertion échoue si la clé existe
//    déjà (contrainte d'unicité), ce qui évite tout double traitement, même sur deux requêtes simultanées.
$cle = $livraison !== '' ? $livraison : ($donnees['transactionId'] ?? '') . ':' . $message['event'];
$requete = $pdo->prepare('INSERT IGNORE INTO webhooks_recus (cle, recu_le) VALUES (?, NOW())');
$requete->execute([$cle]);
if ($requete->rowCount() === 0) {
    http_response_code(200); // déjà traité : on acquitte pour arrêter les relances
    exit;
}

// 4. Répondre vite, traiter ensuite (file d'attente, tâche de fond…).
http_response_code(200);

if ($message['event'] === 'payment.success' && ($donnees['mode'] ?? '') === 'LIVE') {
    // Contrôle : montant et devise de payment.request doivent correspondre à la commande.
    // Puis : marquer la commande payée, livrer, envoyer la confirmation.
}
Node.js
// Node.js 18+ (Express). Le corps est un objet JSON { payload: "…" }.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.CASHCHAP_WEBHOOK_SECRET;

app.post('/cashchap/webhook', express.json(), async (req, res) => {
  const payload = req.body.payload ?? '';
  const signature = req.get('X-CashChap-Signature') ?? '';
  const livraison = req.get('X-CashChap-Delivery') ?? '';

  // 1. Signature : HMAC-SHA256 du champ payload, comparaison en temps constant.
  const attendue = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  if (signature.length !== attendue.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(attendue))) {
    return res.sendStatus(403);
  }

  // 2. Déchiffrement : 16 octets d'IV puis le texte chiffré (base64) en AES-256-CBC.
  const binaire = Buffer.from(payload, 'base64');
  const iv = binaire.subarray(0, 16);
  const chiffre = Buffer.from(binaire.subarray(16).toString('latin1'), 'base64');
  // Clé AES-256 : le secret (UUID de 36 caractères) tronqué à 32 octets, comme le fait OpenSSL côté PHP.
  const cle = Buffer.alloc(32);
  Buffer.from(secret).copy(cle, 0, 0, 32);
  const dechiffreur = crypto.createDecipheriv('aes-256-cbc', cle, iv);
  const json = Buffer.concat([dechiffreur.update(chiffre), dechiffreur.final()]).toString('utf8');
  const message = JSON.parse(json);
  // data est une chaîne JSON (double encodage) : second décodage.
  const donnees = typeof message.data === 'string' ? JSON.parse(message.data) : message.data;

  // 3. Dédoublonnage par identifiant de livraison (ou transactionId + événement) : insertion sous contrainte
  //    d'unicité ; si la clé existe déjà, on acquitte sans retraiter.
  const cleLivraison = livraison || `${donnees.transactionId}:${message.event}`;
  const inedit = await enregistrerSiNouveau(cleLivraison); // false si déjà vue
  if (!inedit) {
    return res.sendStatus(200);
  }

  // 4. Répondre vite, traiter ensuite.
  res.sendStatus(200);
  // Traitement asynchrone : contrôle de donnees.mode, du montant et de la devise, puis mise à jour de la commande.
});

Gérer les webhooks par l'API v2

Avec la clé secrète du service (le mode Live ou Sandbox est celui de la clé), mêmes règles que le portail : adresse https, types du catalogue, limite par service et par mode.

RouteRôle
GET /v2/webhook_event_typesTypes d'événements, par groupe (codes, libellés et descriptions en français).
GET /v2/webhook_endpointsWebhooks du service dans ce mode (sans secret) ; meta.limit et meta.count.
POST /v2/webhook_endpointsAjout : url, label (facultatif), events (["*"] pour tous, ou une liste de codes), enabled (facultatif, true par défaut). Réponse 201 avec secret, rendu cette seule fois. Limite atteinte : 409 webhook_limit_reached.
GET /v2/webhook_endpoints/{id}Un webhook.
DELETE /v2/webhook_endpoints/{id}Suppression définitive.
Ajouter un webhook
curl -X POST https://api.cashchap.com/v2/webhook_endpoints \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.votre-site.bj/cashchap/webhook","label":"Boutique","events":["payment.success","payment.failed"]}'
Réponse (201)
{
  "data": {
    "id": "5c8e…",
    "object": "webhook_endpoint",
    "url": "https://www.votre-site.bj/cashchap/webhook",
    "label": "Boutique",
    "status": "enabled",
    "all_events": false,
    "events": ["payment.success", "payment.failed"],
    "mode": "live",
    "secret_last4": "3f2a",
    "created_at": "2026-10-11T12:00:00Z",
    "secret": "9b0c…-…-3f2a"
  },
  "meta": { "request_id": "req_…" }
}

La modification d'un webhook (adresse, types, état) et le renouvellement du secret se font dans le portail.

Bonnes pratiques

  • Répondez 2xx en moins de dix secondes, puis traitez en arrière-plan (file d'attente, tâche de fond). Un traitement long dans la requête fait dépasser le délai : CashChap compte un échec et relance, alors que vous avez déjà traité.
  • Dédoublonnez avec X-CashChap-Delivery, identique à chaque tentative d'une même livraison, ou à défaut avec le couple transactionId + événement. Enregistrez la clé sous contrainte d'unicité avant de traiter ; une clé déjà vue est acquittée (2xx) sans retraitement.
  • Ne vous fiez jamais à l'ordre d'arrivée : une relance peut arriver après des événements plus récents. Décidez d'après le contenu (status, transactionId), pas d'après la chronologie de réception.
  • Vérifiez la signature avant tout, puis le mode : un webhook Sandbox ne reçoit jamais d'événement Live et inversement, mais contrôlez tout de même data.mode avant de livrer.
  • Contrôlez le montant et la devise (payment.request) par rapport à votre commande, et relisez la transaction par GET /v2/transactions/{id} avec la clé secrète si quelque chose vous paraît incohérent.
  • Gardez un sondage de secours pour les transactions encore PENDING chez vous : après six tentatives manquées, la livraison est abandonnée et l'événement n'est plus renvoyé automatiquement (vous pouvez le relancer depuis le portail).
  • Testez avant la mise en production avec l'événement de test, puis consultez le code HTTP et la réponse enregistrés dans l'historique.
  • Adresse non devinable : ajoutez un segment aléatoire au chemin. Une adresse HTTPS avec certificat valide est de toute façon exigée en Live.