MonCashAPIby FedtopupSe connecter

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).

GET/v1/webhookspermission webhooks:read
POST/v1/webhookspermission webhooks:write
DELETE/v1/webhooks/:idpermission webhooks:write

Les événements

ChampTypeDescription
payment.createdpaiementPaiement créé.
payment.pendingpaiementLa page MonCash est ouverte ; le client n’a pas encore réglé.
payment.succeededpaiementPaiement confirmé : votre solde est crédité.
payment.failedpaiementPaiement refusé par MonCash.
payment.expiredpaiementLien expiré sans paiement.
withdrawal.requestedretraitDemande de retrait enregistrée.
withdrawal.processingretraitTransfert en cours.
withdrawal.completedretraitTransfert envoyé.
withdrawal.rejectedretraitDemande refusée, réservation libérée.
withdrawal.cancelledretraitDemande annulée par le marchand.
withdrawal.failedretraitTransfert 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ête X-MoncashAPI-Timestamp (secondes Unix) ;
  • identifiant : l’en-tête X-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 "", 200

Se 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 forme evt_…) 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.

Une question sur l’intégration ?