Webhooks
Un webhook est une requête POST que MonCashAPI envoie à votre serveur quand quelque chose se produit : un paiement confirmé, un retrait terminé. C’est le moyen le plus fiable de tenir vos commandes à jour.
Enregistrer une adresse
Dans le tableau de bord, page Webhooks, ou par l’API :
curl https://moncashapi.fedtopup.com/api/v1/webhooks \
-H "Authorization: Bearer $MONCASHAPI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://votre-site.com/webhooks/moncashapi",
"events": ["payment.succeeded", "payment.failed", "withdrawal.completed"]}'La réponse contient le secret de signature (whsec_…), affiché une seule fois. Trois adresses au plus par projet. L’adresse doit être en https et publique : les adresses locales ou internes sont refusées (URL_NOT_ALLOWED).
Les événements
| Champ | Type | Description |
|---|---|---|
payment.created | paiement | Paiement créé. |
payment.pending | paiement | La page MonCash est ouverte ; le client n’a pas encore réglé. |
payment.succeeded | paiement | Paiement confirmé : votre solde est crédité. |
payment.failed | paiement | Paiement refusé par MonCash. |
payment.expired | paiement | Lien expiré sans paiement. |
withdrawal.requested | retrait | Demande de retrait enregistrée. |
withdrawal.processing | retrait | Transfert en cours. |
withdrawal.completed | retrait | Transfert envoyé. |
withdrawal.rejected | retrait | Demande refusée, réservation libérée. |
withdrawal.cancelled | retrait | Demande annulée par le marchand. |
withdrawal.failed | retrait | Transfert non abouti, réservation libérée. |
Ce que reçoit votre serveur
POST /webhooks/moncashapi HTTP/1.1
Content-Type: application/json
User-Agent: MonCashAPI-Webhooks/1.0
X-MoncashAPI-Event: payment.succeeded
X-MoncashAPI-Event-ID: evt_0c5a1f6e2b9d4a7c8e1f3a5b7c9d0e2f
X-MoncashAPI-Timestamp: 1790865127
X-MoncashAPI-Signature: v1=5f1c…e9a2
{
"id": "evt_0c5a1f6e2b9d4a7c8e1f3a5b7c9d0e2f",
"type": "payment.succeeded",
"created_at": "2026-09-30T14:38:47.120Z",
"data": {
"object": {
"id": "pay_3f9c1a7e52b04d6a81c0",
"object": "payment",
"status": "succeeded",
"amount": 1000,
"fee": 29,
"total": 1029,
"currency": "HTG",
"reference": "CMD-1042",
"paid_at": "2026-09-30T14:38:46.902Z"
}
}
}data.object est l’objet complet (paiement ou retrait) au moment de l’événement.
Vérifier la signature
La signature est un HMAC-SHA256, en hexadécimal, de la chaîne horodatage.identifiant.corps :
horodatage: l’en-têteX-MoncashAPI-Timestamp(secondes Unix) ;identifiant: l’en-têteX-MoncashAPI-Event-ID;corps: le corps brut de la requête, octet pour octet — pas un JSON re-sérialisé.
L’en-tête X-MoncashAPI-Signature vaut v1=<signature>. Pendant un changement de secret, il en porte deux, séparées par une virgule : acceptez le message si l’une des deux est bonne.
# Recalculer une signature à la main, pour vérifier votre code :
printf '%s.%s.%s' "$HORODATAGE" "$ID_EVENEMENT" "$CORPS_BRUT" \
| openssl dgst -sha256 -hmac "$MONCASHAPI_WEBHOOK_SECRET"
# Le résultat doit être égal à la valeur qui suit « v1= » dans X-MoncashAPI-Signature.// Web Crypto : fonctionne dans les fonctions « edge », Deno, Bun et Node 18+.
export async function verifier(request, secret) {
const corps = await request.text(); // le corps BRUT, avant tout JSON.parse
const horodatage = request.headers.get("X-MoncashAPI-Timestamp") ?? "";
const evenement = request.headers.get("X-MoncashAPI-Event-ID") ?? "";
const recues = (request.headers.get("X-MoncashAPI-Signature") ?? "")
.split(",").map((s) => s.trim().replace(/^v1=/, ""));
const cle = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const octets = await crypto.subtle.sign(
"HMAC", cle, new TextEncoder().encode(`${horodatage}.${evenement}.${corps}`));
const attendue = [...new Uint8Array(octets)].map((o) => o.toString(16).padStart(2, "0")).join("");
const recent = Math.abs(Date.now() / 1000 - Number(horodatage)) < 300;
if (!recent || !recues.includes(attendue)) return null;
return JSON.parse(corps);
}import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw : la signature porte sur les octets reçus, pas sur un JSON re-sérialisé.
app.post("/webhooks/moncashapi", express.raw({ type: "application/json" }), (req, res) => {
const corps = req.body.toString("utf8");
const horodatage = req.get("X-MoncashAPI-Timestamp") ?? "";
const evenement = req.get("X-MoncashAPI-Event-ID") ?? "";
const recues = (req.get("X-MoncashAPI-Signature") ?? "")
.split(",").map((s) => s.trim().replace(/^v1=/, ""));
const attendue = crypto
.createHmac("sha256", process.env.MONCASHAPI_WEBHOOK_SECRET)
.update(`${horodatage}.${evenement}.${corps}`)
.digest("hex");
const valide = recues.some((s) =>
s.length === attendue.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(attendue)));
const recent = Math.abs(Date.now() / 1000 - Number(horodatage)) < 300;
if (!valide || !recent) return res.sendStatus(401);
const message = JSON.parse(corps);
// Un même événement peut arriver deux fois : ignorez un message.id déjà traité.
if (message.type === "payment.succeeded") {
const paiement = message.data.object;
// paiement.reference est votre numéro de commande : marquez-la payée.
}
res.sendStatus(200);
});<?php
$corps = file_get_contents("php://input"); // le corps BRUT
$horodatage = $_SERVER["HTTP_X_MONCASHAPI_TIMESTAMP"] ?? "";
$evenement = $_SERVER["HTTP_X_MONCASHAPI_EVENT_ID"] ?? "";
$recues = array_map(
fn($s) => preg_replace('/^v1=/', '', trim($s)),
explode(",", $_SERVER["HTTP_X_MONCASHAPI_SIGNATURE"] ?? "")
);
$attendue = hash_hmac("sha256", "$horodatage.$evenement.$corps", getenv("MONCASHAPI_WEBHOOK_SECRET"));
$valide = false;
foreach ($recues as $s) {
if (hash_equals($attendue, $s)) { $valide = true; }
}
if (!$valide || abs(time() - (int) $horodatage) > 300) {
http_response_code(401);
exit;
}
$message = json_decode($corps, true);
// Un même événement peut arriver deux fois : ignorez un $message["id"] déjà traité.
if ($message["type"] === "payment.succeeded") {
$paiement = $message["data"]["object"];
// $paiement["reference"] est votre numéro de commande : marquez-la payée.
}
http_response_code(200);import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/moncashapi")
def webhook():
corps = request.get_data() # le corps BRUT
horodatage = request.headers.get("X-MoncashAPI-Timestamp", "")
evenement = request.headers.get("X-MoncashAPI-Event-ID", "")
recues = [s.strip().removeprefix("v1=")
for s in request.headers.get("X-MoncashAPI-Signature", "").split(",")]
attendue = hmac.new(
os.environ["MONCASHAPI_WEBHOOK_SECRET"].encode(),
f"{horodatage}.{evenement}.".encode() + corps,
hashlib.sha256,
).hexdigest()
valide = any(hmac.compare_digest(attendue, s) for s in recues)
recent = horodatage.isdigit() and abs(time.time() - int(horodatage)) < 300
if not (valide and recent):
return "", 401
message = request.get_json()
# Un même événement peut arriver deux fois : ignorez un message["id"] déjà traité.
if message["type"] == "payment.succeeded":
paiement = message["data"]["object"]
# paiement["reference"] est votre numéro de commande : marquez-la payée.
return "", 200Se protéger du rejeu
- Refusez un message dont l’horodatage a plus de cinq minutes d’écart avec votre horloge.
- Gardez les identifiants d’événement déjà traités (
id, de la formeevt_…) et ignorez un doublon : un même événement peut être livré plus d’une fois.
Répondre
Répondez par un code 2xx en moins de dix secondes. Faites le travail long après avoir répondu. Les redirections ne sont pas suivies : une réponse 3xx compte comme un échec.
Relances
Si votre serveur ne répond pas 2xx, l’envoi est relancé après 1 minute, 5 minutes, 15 minutes, 1 heure, 6 heures, puis 24 heures. Après ces tentatives, il est abandonné et reste visible dans le tableau de bord, où le bouton « Renvoyer » le remet dans la file.
Après vingt échecs d’affilée, l’adresse est mise en pause ; réactivez-la depuis le tableau de bord une fois votre serveur réparé.
Suivre les envois
Pour chaque envoi, le tableau de bord affiche l’événement, le statut, le nombre de tentatives, le code HTTP, la latence et la dernière erreur. « Envoyer un test » émet un événement webhook.ping signé comme les autres.
Changer de secret
« Changer le secret de signature » crée un nouveau secret. Pendant 24 heures, chaque envoi porte les deux signatures : vous déployez le nouveau secret sans perdre un seul événement.