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
- 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).
- 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). - 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.
- 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é.
- Cliquez sur Tester en choisissant un type d'événement : voir les événements de test.
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
httpvershttpsou deexemple.bjverswww.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énement | Libellé | Quand |
|---|---|---|
payment.success | Paiement réussi | Un encaissement (Mobile Money, carte, lien de paiement, boutique) est validé : le payeur a été débité. |
payment.failed | Paiement échoué | Un encaissement a échoué ou a été refusé, avec la raison de l'échec. |
Retraits et reversements
| Événement | Libellé | Quand |
|---|---|---|
withdrawal.pending | Virement bancaire enregistré | Une demande de virement bancaire est enregistrée et attend son traitement par l'équipe CashChap. |
withdrawal.success | Retrait effectué | Un versement automatique vers Mobile Money ou un virement bancaire est effectué. |
withdrawal.failed | Retrait échoué | Un versement automatique ou un virement bancaire a été refusé par l'opérateur ou la banque. |
withdrawal.cancelled | Virement bancaire annulé | Une demande de virement bancaire a été annulée avant son traitement. |
Remboursements
| Événement | Libellé | Quand |
|---|---|---|
refund.pending | Remboursement demandé | Une demande de remboursement est enregistrée et attend la validation de CashChap. |
refund.success | Remboursement versé | Le remboursement a été versé au payeur. |
refund.rejected | Remboursement refusé | La demande de remboursement a été refusée par CashChap. |
refund.failed | Remboursement échoué | Le versement du remboursement a échoué chez l'opérateur ; le montant revient sur votre solde. |
refund.cancelled | Remboursement annulé | La demande de remboursement a été annulée avant sa validation. |
Fonds
| Événement | Libellé | Quand |
|---|---|---|
funds.held | Fonds retenus | Les fonds d'un paiement validé sont retenus pour vérification : ils n'entrent pas encore dans votre solde disponible. |
funds.released | Fonds libérés | Les 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.
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:datane contient aucune transaction :test(true),event,message,mode,service_env_uuidetsent_at.- Exemple d'un type (
payment.success,refund.failed…) :dataa la même forme que la vraie charge, avec une transaction fictive (identifiant aléatoire inconnu de CashChap) ettestà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 :
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ête | Contenu |
|---|---|
X-CashChap-Signature | HMAC-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-Delivery | Identifiant 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-Attempt | Numé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 :
{"event":"payment.success","data":"{\"status\":\"VALIDATED\",\"transactionId\":\"2f1c3b6e-9a0d-4c1e-8f5a-2b7d9e4c6a10\",\"payment\":{\"provider\":\"mtn-benin\",…},\"mode\":\"LIVE\"}"}
{
"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 :
| Tentative | Envoyée |
|---|---|
| 1 | dès l'événement |
| 2 | 1 minute après l'échec de la 1re |
| 3 | 5 minutes après l'échec de la 2e |
| 4 | 30 minutes après l'échec de la 3e |
| 5 | 2 heures après l'échec de la 4e |
| 6 | 6 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-Deliverypar 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.successrelancé 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 :
| Statut | Signification |
|---|---|
| Livrée | Votre serveur a répondu 2xx : rien à renvoyer. |
| En attente | Tentative en cours ou planifiée (le prochain essai est indiqué). |
| Échouée | La dernière tentative a échoué ; une relance automatique est planifiée. |
| Abandonnée | Six 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.
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
// 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 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.
| Route | Rôle |
|---|---|
GET /v2/webhook_event_types | Types d'événements, par groupe (codes, libellés et descriptions en français). |
GET /v2/webhook_endpoints | Webhooks du service dans ce mode (sans secret) ; meta.limit et meta.count. |
POST /v2/webhook_endpoints | Ajout : 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. |
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"]}'
{
"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 coupletransactionId+ é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.modeavant de livrer. - Contrôlez le montant et la devise (
payment.request) par rapport à votre commande, et relisez la transaction parGET /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.