Documentation de l'API Monea
Encaissez en MTN MoMo et Moov Money au Bénin. Trois appels suffisent : vous créez une session, vous y envoyez votre client, vous recevez un webhook signé.
Introduction
Toutes les requêtes se font en HTTPS vers https://monea.qzz.io. Un domaine définitif remplacera cette adresse au passage en production.
Trois conventions à connaître
- Les montants sont des chaînes de caractères, pas des nombres :
"25000". Le franc CFA n'a pas de sous-unité, et une décimale non nulle est refusée plutôt que tronquée. - Les opérateurs ne sont pas une liste figée. Ils se lisent sur
GET /api/catalog, qui expose les pays, opérateurs et devises réellement disponibles — aujourd'hui 16 pays et 31 opérateurs. N'en codez aucun en dur : la liste évolue sans préavis. - Le nombre de décimales dépend du couple opérateur + devise, jamais de la devise seule. Le champ
decimalsvautNONEouTWO_PLACES. CDF, TZS et UGX ont les deux selon l'opérateur. - Les numéros sont en chiffres uniquement, indicatif pays inclus et sans zéro initial :
22951345789.
La commission Monea est prélevée sur vous, jamais sur le payeur : votre client est débité exactement du montant affiché, et votre solde est crédité du montant moins la commission. Chaque transaction porte le détail dans son champ fee.
Authentification
Votre dashboard vous délivre quatre clés : une paire pour le sandbox, une pour la production. Les clés de production n'apparaissent qu'après validation de votre dossier KYB.
| Clé | Usage |
|---|---|
| pk_sandbox_fLx_… pk_live_fLx_… | Clé publique. Exposable côté client. |
| sk_sandbox_fLx_… sk_live_fLx_… | Clé secrète. Depuis votre serveur uniquement. |
Authorization: Bearer sk_sandbox_fLx_LtLK7JiIZyjcyiY6CIPdJHIjUne clé secrète n'est affichée qu'une seule fois, à sa génération : nous n'en conservons qu'une empreinte, pas la valeur. Si vous la perdez, régénérez-la — l'ancienne est invalidée immédiatement.
Créer une session
Depuis votre serveur, avec votre clé secrète. Seul amount est obligatoire ; reference est votre identifiant de commande, et customerName alimente la colonne « Client » de votre dashboard — nous ne recevons aucun nom de l'opérateur.
POST /api/checkout/session HTTP/1.1
Host: monea.qzz.io
Authorization: Bearer sk_sandbox_fLx_...
Content-Type: application/json
{
"amount": "25000",
"reference": "CMD-4820",
"customerName": "Chabi Adéyèmi",
"description": "Commande #4820"
}{
"sessionId": "cb9a2435-d924-4f40-8832-441841ba3c7f",
"checkoutUrl": "/checkout?session=cb9a2435-d924-4f40-8832-441841ba3c7f",
"expiresAt": "2026-08-08T23:31:48.518Z"
}Redirigez ensuite votre client vers checkoutUrl. La session expire au bout de 30 minutes et ne peut déclencher qu'un seul paiement.
Encaisser
Notre page hébergée fait déjà ce travail. Ces deux appels ne vous concernent que si vous préférez construire votre propre écran de paiement — ils sont publics et n'exigent aucune clé, la session servant de jeton d'accès.
Lire la session à afficher
{
"amount": "25000",
"currency": "XOF",
"merchantName": "Ako Boutique",
"reference": "CMD-4820",
"description": "Commande #4820",
"limits": {
"MTN_MOMO_BEN": { "minAmount": "1", "maxAmount": "2000000" },
"MOOV_BEN": { "minAmount": "100", "maxAmount": "2000000" }
}
}Les bornes de limits viennent de l'opérateur et diffèrent d'un réseau à l'autre — 1 XOF minimum sur MTN, 100 sur Moov. Lisez-les plutôt que de les coder en dur.
Déclencher la demande de paiement
POST /api/checkout/pay HTTP/1.1
Content-Type: application/json
{
"sessionId": "cb9a2435-d924-4f40-8832-441841ba3c7f",
"provider": "MTN_MOMO_BEN",
"phoneNumber": "22951345789"
}{
"depositId": "09625597-958b-4569-907b-5b0f01dca685"
}Le client reçoit alors une demande de confirmation sur son téléphone et valide par son code secret. Il voit s'afficher FLUXA 09625597 — le préfixe Monea suivi des huit premiers caractères du depositId, utile en cas de réclamation.
Suivre le statut
Pour l'écran d'attente. Interrogez toutes les trois secondes, le temps que le client valide.
{
"status": "pending",
"failureMessage": null
}| Statut | Signification |
|---|---|
| pending | En cours. Ne livrez rien. |
| completed | Encaissé. Votre solde est crédité. |
| failed | Échoué. Aucun montant débité ; le motif est dans failureMessage. |
Un paiement peut rester en attente plusieurs minutes
Certaines transactions passent par une phase de réconciliation chez l'opérateur, qui n'a alors pas encore tranché. Nous ne les déclarons jamais échouées d'office. Traitez pending comme « pas encore payé », jamais comme un échec — et fiez-vous au webhook plutôt qu'à un délai.
Page hébergée
Si vous préférez ne rien héberger du tout, Monea peut vous rendre une page de paiement clés en main. Vous y redirigez votre client, il choisit son opérateur, saisit son code, et revient chez vous.
C’est une alternative à la page de paiement Monea, pas un remplacement. Vous gagnez de n’avoir aucune interface à écrire ; vous perdez le détail montant / frais / total que la page Monea affiche, et votre client ne voit plus la marque qui l’a amené jusque-là.
POST /api/checkout/payment-page HTTP/1.1
Host: monea.qzz.io
Authorization: Bearer sk_live_fLx_...
Content-Type: application/json
{
"amount": "1000",
"currency": "XOF",
"country": "BEN",
"returnUrl": "https://votre-site.bj/commande/1234/retour",
"reference": "CMD-1234",
"description": "Sac artisanal",
"phoneNumber": "2290167040007",
"language": "FR"
}{
"depositId": "6451791f-1601-4b5e-a829-96e4b9ed1ed7",
"redirectUrl": "https://paywith.pawapay.io/v2?...",
"amount": "1000",
"fee": "100",
"total": "1100",
"currency": "XOF",
"expiresAt": "2026-09-11T11:08:10.553Z"
}Redirigez votre client vers redirectUrl. amount est ce que vous recevrez ; total est ce que règle votre client, commission comprise. country est obligatoire dès lors qu’un montant est fixé.
Deux choses à savoir avant de l’utiliser
- La page expire au bout de 15 minutes, sans rien vous notifier. Aucun webhook n’est émis à l’expiration : si vous n’écoutez que les webhooks, un panier abandonné restera en suspens pour toujours. Interrogez le statut.
- L’URL de retour ne reçoit aucun statut. Ce n’est qu’une destination. Le sort du paiement se lit au webhook, ou ci-dessous.
GET /api/checkout/payment-page/{depositId} HTTP/1.1
Host: monea.qzz.io
Authorization: Bearer sk_live_fLx_...
# status vaut null tant que votre client n'a pas appuye sur payer :
# le depot n'existe pas encore, et l'operateur n'est pas choisi.Webhooks
Configurez votre URL dans le dashboard, une fois pour toutes — elle ne se passe pas par paiement. Elle doit être publique et en HTTPS. Répondez 200 pour confirmer : sans quoi nous réessayons cinq fois, en espaçant progressivement de 30 secondes à 4 minutes — soit environ huit minutes pour revenir en ligne. Passé ce délai, renvoyez l'événement depuis votre dashboard.
X-Monea-Signature: t=1786230167,v1=ac467b4b6d40da941b7d4ac0fe316258c9f40b71b1bdb9a95fb7c0b2ec782fe7
X-Monea-Event: deposit.completed
X-Monea-Delivery: c8605320-14ee-442c-8bdf-11b0c2097d18
Content-Type: application/json
User-Agent: Monea-Webhooks/1.0{
"event": "deposit.completed",
"createdAt": "2026-08-08T23:02:47.335Z",
"data": {
"depositId": "0ff96694-4a03-45b5-8f30-5dd7d5115d51",
"reference": "CMD-4820",
"amount": "25000",
"currency": "XOF",
"fee": "2500",
"provider": "MTN_MOMO_BEN",
"payerPhone": "22951345789",
"customerName": "Chabi Adéyèmi",
"status": "completed",
"failureCode": null,
"failureMessage": null
}
}Vérifier la signature
À faire systématiquement. Sans cette vérification, n'importe qui connaissant votre URL pourrait vous annoncer un paiement réussi. L'horodatage est inclus dans la valeur signée, ce qui rend un rejeu détectable — refusez au-delà de cinq minutes.
import { createHmac, timingSafeEqual } from 'node:crypto'
// Le corps BRUT, avant tout parsing JSON : une réécriture de l'objet
// changerait l'ordre des clés et invaliderait la signature.
export function verifierWebhook(header, corpsBrut, secret) {
const parts = new Map(
header.split(',').map((p) => {
const [cle, ...reste] = p.split('=')
return [cle.trim(), reste.join('=').trim()]
}),
)
const horodatage = Number(parts.get('t'))
const fourni = parts.get('v1')
if (!Number.isFinite(horodatage) || !fourni) return false
// Au-delà de 5 minutes, on considère qu'il s'agit d'un rejeu.
if (Math.abs(Math.floor(Date.now() / 1000) - horodatage) > 300) return false
const attendu = createHmac('sha256', secret)
.update(`${horodatage}.${corpsBrut}`, 'utf8')
.digest('hex')
if (attendu.length !== fourni.length) return false
return timingSafeEqual(Buffer.from(attendu, 'hex'), Buffer.from(fourni, 'hex'))
}Événements émis
- deposit.completed
- deposit.failed
- payout.completed
- payout.failed
- kyb.approved
- kyb.rejected
Seuls les états définitifs déclenchent un envoi — aucun webhook pour un paiement encore en cours. Un même événement n'est envoyé qu'une fois, même si l'opérateur nous notifie en double.
Erreurs et échecs
Une requête refusée renvoie un code HTTP et un message directement affichable — il dit ce qui est attendu, pas seulement ce qui a échoué.
{
"message": "Montant hors limites pour ce moyen de paiement : 100 a 2000000 XOF",
"error": "Bad Request",
"statusCode": 400
}| Code | Cause |
|---|---|
| 400 | Montant hors limites, numéro invalide, session expirée ou déjà payée |
| 401 | Clé API absente ou invalide |
| 403 | Clé de production utilisée avant validation de votre dossier KYB |
| 404 | Session ou paiement introuvable |
| 429 | Limite de débit atteinte — voir ci-dessous |
| 503 | Opérateur momentanément injoignable. Ne rejouez pas un paiement : consultez son statut |
Motifs d'échec d'un paiement
Transmis dans failureCode sur les webhooks deposit.failed.
| PAYER_NOT_FOUND | Le numéro n’a pas de compte Mobile Money actif |
| PAYMENT_NOT_APPROVED | Le payeur n’a pas validé sur son téléphone |
| INSUFFICIENT_BALANCE | Solde insuffisant sur le compte du payeur |
| PAYER_LIMIT_REACHED | Plafond de l’opérateur atteint par le payeur |
| WALLET_LIMIT_REACHED | Plafond du portefeuille atteint |
| PAYMENT_IN_PROGRESS | Un autre paiement est déjà en cours sur ce numéro |
| UNSPECIFIED_FAILURE | Échec sans cause précisée par l’opérateur |
| FLUXA_TIMEOUT | Aucune confirmation dans le délai imparti — aucun débit |
Limites
Au-delà, l'API renvoie 429. Ces seuils couvrent largement un usage normal ; ils se remarquent surtout en test rapide.
| Opération | Requêtes | Fenêtre |
|---|---|---|
| Créer une session, encaisser | 10 | 1 minute |
| Lire une session | 60 | 1 minute |
| Suivre un statut | 120 | 1 minute |
| Demander un retrait | 5 | 1 minute |
| Par défaut | 120 | 1 minute |
Les montants, eux, sont bornés par l'opérateur et non par nous : lisez limits sur la session plutôt que de figer des valeurs.
Tester en sandbox
Vos clés sk_sandbox_ n'engagent aucun mouvement réel. Deux numéros pour commencer : 22951345789 réussit sur MTN, 22995345789 sur Moov. Demandez-nous le jeu complet pour éprouver chaque motif d'échec.