Aller au contenu
Monea
Retour à l'accueil
Sandbox

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 decimals vaut NONE ou TWO_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.
bash
Authorization: Bearer sk_sandbox_fLx_LtLK7JiIZyjcyiY6CIPdJHIj

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

requête
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"
}
réponse · 201
{
  "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

GET /api/checkout/session/:sessionId
{
  "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

requête
POST /api/checkout/pay HTTP/1.1
Content-Type: application/json

{
  "sessionId": "cb9a2435-d924-4f40-8832-441841ba3c7f",
  "provider": "MTN_MOMO_BEN",
  "phoneNumber": "22951345789"
}
réponse · 200
{
  "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.

GET /api/checkout/status/:depositId
{
  "status": "pending",
  "failureMessage": null
}
StatutSignification
pendingEn cours. Ne livrez rien.
completedEncaissé. 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à.

requête
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"
}
réponse · 201
{
  "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.
suivre la page
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.

en-têtes reçus
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
corps
{
  "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.

vérification
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é.

réponse · 400
{
  "message": "Montant hors limites pour ce moyen de paiement : 100 a 2000000 XOF",
  "error": "Bad Request",
  "statusCode": 400
}
CodeCause
400Montant hors limites, numéro invalide, session expirée ou déjà payée
401Clé API absente ou invalide
403Clé de production utilisée avant validation de votre dossier KYB
404Session ou paiement introuvable
429Limite de débit atteinte — voir ci-dessous
503Opé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_FOUNDLe numéro n’a pas de compte Mobile Money actif
PAYMENT_NOT_APPROVEDLe payeur n’a pas validé sur son téléphone
INSUFFICIENT_BALANCESolde insuffisant sur le compte du payeur
PAYER_LIMIT_REACHEDPlafond de l’opérateur atteint par le payeur
WALLET_LIMIT_REACHEDPlafond du portefeuille atteint
PAYMENT_IN_PROGRESSUn autre paiement est déjà en cours sur ce numéro
UNSPECIFIED_FAILUREÉchec sans cause précisée par l’opérateur
FLUXA_TIMEOUTAucune 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érationRequêtesFenêtre
Créer une session, encaisser101 minute
Lire une session601 minute
Suivre un statut1201 minute
Demander un retrait51 minute
Par défaut1201 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.