Démarrer
Votre premier paiement
Encaisser un client en Mobile Money tient en un appel. Ce guide déroule la séquence complète : choisir l'opérateur, annoncer le bon montant, créer le paiement, puis confirmer qu'il a abouti.
Avec une clé sk_test_…, tout ce guide fonctionne à l'identique sans qu'aucun
argent ne circule, et le numéro du payeur décide du scénario joué —
voir Mode test. Avec une clé live, en revanche, chaque appel
débite un vrai compte : faites votre premier essai avec un petit montant, sur un numéro
qui vous appartient.
Vue d'ensemble
-
Choisir un moyen de paiement
GET /payment-modesvous donne les opérateurs actifs pour un pays. -
Annoncer le montant total
POST /payment-modes/feescalcule ce que le client sera réellement débité. -
Créer le paiement
POST /paymentsdéclenche la demande de validation sur le téléphone du client. -
Confirmer le résultat
Un webhook vous notifie ; à défaut, interrogez
GET /payments/{reference}.
1. Choisir un moyen de paiement
Un paiement cible un opérateur précis. Récupérez la liste de ceux qui sont ouverts sur votre compte, filtrée par pays :
curl "https://api.mivaapay.com/api/v1/payment-modes?country=BJ" \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"
$modes = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
->get('https://api.mivaapay.com/api/v1/payment-modes', ['country' => 'BJ'])
->json('data');
const res = await fetch('https://api.mivaapay.com/api/v1/payment-modes?country=BJ', {
headers: { Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}` }
});
const { data: modes } = await res.json();
modes = requests.get(
'https://api.mivaapay.com/api/v1/payment-modes',
params={'country': 'BJ'},
headers={'Authorization': f"Bearer {SECRET_KEY}"},
timeout=30,
).json()['data']
{
"success": true,
"data": [
{
"id": 1,
"name": "MTN Bénin",
"type": "mobile_money",
"operator": "mtn",
"country": "Bénin",
"country_code": "BJ",
"fee_percent": 1.8,
"fee_fixed": 0,
"requires_otp": false,
"logo": "https://api.mivaapay.com/images/modes/mtn.png"
}
]
}
Retenez le champ operator (ou id) : c'est lui que vous passerez à la
création du paiement. Notez aussi requires_otp, qui change le parcours client
(voir plus bas). La liste complète des opérateurs par pays
est sur la page Opérateurs & pays.
2. Annoncer le montant total
Le champ amount est ce que vous encaissez. La commission MivaaPay
s'ajoute par-dessus et c'est le client qui la paie. Avant d'afficher un prix, demandez le total :
curl -X POST https://api.mivaapay.com/api/v1/payment-modes/fees \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "amount": 5000, "operator": "mtn" }'
$quote = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
->post('https://api.mivaapay.com/api/v1/payment-modes/fees', [
'amount' => 5000,
'operator' => 'mtn',
])
->json('data');
const res = await fetch('https://api.mivaapay.com/api/v1/payment-modes/fees', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: 5000, operator: 'mtn' })
});
const { data: quote } = await res.json();
quote = requests.post(
'https://api.mivaapay.com/api/v1/payment-modes/fees',
json={'amount': 5000, 'operator': 'mtn'},
headers={'Authorization': f"Bearer {SECRET_KEY}"},
timeout=30,
).json()['data']
{
"success": true,
"data": {
"payment_mode_id": 1,
"operator": "mtn",
"amount": 5000,
"fee_amount": 90,
"total_amount": 5090,
"fee_percent": 1.8,
"fee_fixed": 0
}
}
Le client verra donc 5 090 XOF sur son téléphone, et vous encaisserez 5 000 XOF. Afficher ce total avant la validation évite l'essentiel des abandons et des réclamations.
3. Créer le paiement
Un seul appel suffit. Dès qu'il aboutit, l'opérateur envoie une demande de validation sur le téléphone du client.
curl -X POST https://api.mivaapay.com/api/v1/payments \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XOF",
"operator": "mtn",
"phone_number": "22997000000",
"description": "Commande #1042",
"external_id": "cmd_1042",
"callback_url": "https://boutique.example.com/webhooks/mivaapay",
"customer": {
"firstname": "Awa",
"lastname": "Diallo",
"email": "awa@example.com"
},
"metadata": { "panier": "1042" }
}'
$payment = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
->post('https://api.mivaapay.com/api/v1/payments', [
'amount' => 5000,
'currency' => 'XOF',
'operator' => 'mtn',
'phone_number' => '22997000000',
'description' => 'Commande #1042',
'external_id' => 'cmd_1042',
'callback_url' => 'https://boutique.example.com/webhooks/mivaapay',
'customer' => [
'firstname' => 'Awa',
'lastname' => 'Diallo',
'email' => 'awa@example.com',
],
])
->json('data');
const res = await fetch('https://api.mivaapay.com/api/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: 5000,
currency: 'XOF',
operator: 'mtn',
phone_number: '22997000000',
description: 'Commande #1042',
external_id: 'cmd_1042',
callback_url: 'https://boutique.example.com/webhooks/mivaapay',
customer: { firstname: 'Awa', lastname: 'Diallo', email: 'awa@example.com' }
})
});
const { data: payment } = await res.json();
payment = requests.post(
'https://api.mivaapay.com/api/v1/payments',
json={
'amount': 5000,
'currency': 'XOF',
'operator': 'mtn',
'phone_number': '22997000000',
'description': 'Commande #1042',
'external_id': 'cmd_1042',
'callback_url': 'https://boutique.example.com/webhooks/mivaapay',
'customer': {'firstname': 'Awa', 'lastname': 'Diallo', 'email': 'awa@example.com'},
},
headers={'Authorization': f"Bearer {SECRET_KEY}"},
timeout=30,
).json()['data']
{
"success": true,
"data": {
"reference": "9f2c1a44-8b0e-4b2e-9b0a-2f1d6e5c7a31",
"identifier": "Id2841",
"external_id": "cmd_1042",
"status": "pending",
"amount": 5000,
"fee_amount": 90,
"total_amount": 5090,
"currency": "XOF",
"description": "Commande #1042",
"payment_mode": {
"id": 1,
"name": "MTN Bénin",
"type": "mobile_money",
"operator": "mtn"
},
"customer": {
"firstname": "Awa",
"lastname": "Diallo",
"email": "awa@example.com",
"phone_number": "22997000000"
},
"payment_url": null,
"metadata": { "panier": "1042" },
"created_at": "2026-08-05T10:24:11+00:00",
"updated_at": "2026-08-05T10:24:11+00:00"
}
}
Conservez reference : c'est l'identifiant du paiement chez MivaaPay, celui que
vous utiliserez pour le suivi, le remboursement et tout échange avec le support.
Le paiement revient pending : la demande est partie, le client n'a pas encore
saisi son code. Ne livrez jamais la commande sur la seule réponse à POST /payments.
Attendez succeeded, comme décrit dans Suivre un paiement.
Champs de la requête
| Champ | Requis | Description |
|---|---|---|
amount |
oui | Ce que vous encaissez, en unité principale. Entre 1 et 99 999 999. |
operator |
oui* | Code opérateur, ex. mtn. *Requis si payment_mode_id est absent. |
payment_mode_id |
oui* | Alternative à operator : l'id du catalogue. |
phone_number |
oui | Numéro du payeur au format international sans espaces, ex. 22997000000. Requis en Mobile Money. |
currency |
non | XOF (défaut), XAF, EUR ou USD. |
external_id |
non | Votre propre identifiant de commande. Rend l'appel idempotent — voir plus bas. |
description |
non | Libellé de la commande, visible dans votre back-office. |
callback_url |
non | Endpoint webhook propre à ce paiement. Prioritaire sur l'endpoint global du compte. |
return_url |
non | Page vers laquelle renvoyer le client après paiement (carte, et certains opérateurs). |
otp |
non | Code saisi par le client, requis pour les opérateurs à OTP. |
customer |
non | Objet { firstname, lastname, email, address, country }. Obligatoire en carte. |
metadata |
non | Objet libre, restitué tel quel dans les réponses et les webhooks. |
4. Confirmer le résultat
Le client valide sur son téléphone, puis MivaaPay vous notifie sur votre endpoint webhook. C'est le moyen recommandé : pas de boucle d'attente, pas de quota consommé.
{
"id": "5c8b8a1e-1f0e-4c0a-9a3e-6f8c2b1d4e77",
"event": "payment.succeeded",
"created_at": "2026-08-05T10:25:02+00:00",
"data": {
"reference": "9f2c1a44-8b0e-4b2e-9b0a-2f1d6e5c7a31",
"external_id": "cmd_1042",
"status": "succeeded",
"amount": 5000,
"total_amount": 5090
}
}
Si vous ne pouvez pas exposer d'endpoint public, interrogez
GET /payments/{reference}. Les deux approches, et leurs pièges, sont détaillées dans
Suivre un paiement.
Idempotence
Un external_id est unique par marchand. Si vous renvoyez le même,
MivaaPay ne crée pas un second paiement : il retourne celui qui existe déjà. Un timeout
réseau ou un double clic ne peut donc pas débiter deux fois votre client.
Utilisez votre propre identifiant de commande — cmd_1042, facture-2026-118 —
et rejouez l'appel à l'identique en cas de doute.
Sans external_id, l'appel n'est pas idempotent : deux POST /payments
identiques créent deux paiements distincts et débitent deux fois le client.
Opérateurs à code OTP
Chez certains opérateurs, le client génère lui-même un code à usage unique (souvent par un code
USSD) et vous le communique ; c'est ce code que vous transmettez dans otp.
Ces opérateurs sont signalés par requires_otp: true dans le catalogue :
coris, orange_bf, wave_bf et orange_sn.
Le parcours devient donc : le client obtient son code → vous le saisissez dans votre
formulaire → vous appelez POST /payments avec otp. Sans ce champ,
l'API répond :
{
"success": false,
"error": {
"code": "invalid_request",
"message": "Cet opérateur exige un code OTP saisi par le client (champ « otp »).",
"details": {
"otp": ["OTP requis pour l'opérateur orange_sn."]
}
}
}
Paiement par carte
Pour une carte (VISA, Mastercard), le parcours change : il n'y a pas de numéro de téléphone à débiter, mais une page de paiement hébergée vers laquelle rediriger le client. L'identité du porteur est obligatoire.
{
"amount": 25000,
"currency": "XOF",
"operator": "VISA",
"description": "Abonnement annuel",
"external_id": "sub_2026_118",
"return_url": "https://boutique.example.com/merci",
"customer": {
"firstname": "Awa",
"lastname": "Diallo",
"email": "awa@example.com",
"country": "Benin"
}
}
La réponse porte alors une payment_url :
{
"success": true,
"data": {
"reference": "b71e0c9d-3a4f-4f11-9c22-7d5a1e0b3c68",
"status": "pending",
"payment_url": "https://checkout.feexpay.me/card/…"
}
}
Redirigez le client vers cette URL. À la fin du parcours, il revient sur votre
return_url — mais ce retour n'est pas une preuve de paiement
(le client peut fermer l'onglet, ou revenir sans avoir payé). Attendez le webhook, ou vérifiez
avec GET /payments/{reference}.
customer.firstname, customer.lastname et customer.email
sont exigés ; leur absence renvoie un invalid_request détaillant les champs manquants.
phone_number, en revanche, n'est pas requis.
Erreurs courantes au démarrage
| Symptôme | Cause |
|---|---|
invalid_request — « Moyen de paiement introuvable ou inactif » |
Le code opérateur est mal orthographié, ou ce moyen n'est pas ouvert sur votre compte. Vérifiez avec GET /payment-modes. |
invalid_request — « montant hors des limites » |
Le total débité (montant + commission) doit tenir entre 10 et 1 000 000. Le message indique le total calculé. |
invalid_request — format du numéro |
phone_number doit faire 8 à 15 chiffres, sans espaces ni tirets, indicatif pays compris. |
payment_failed |
L'opérateur a refusé (solde insuffisant, numéro inconnu, service indisponible). Le paiement est enregistré en failed ; c'est au client de réessayer. |
merchant_inactive |
Votre KYC n'est pas validé. L'API reste fermée tant que le compte n'est pas actif. |