Docs API

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.

Faites-le d'abord en mode test

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

  1. Choisir un moyen de paiement

    GET /payment-modes vous donne les opérateurs actifs pour un pays.

  2. Annoncer le montant total

    POST /payment-modes/fees calcule ce que le client sera réellement débité.

  3. Créer le paiement

    POST /payments déclenche la demande de validation sur le téléphone du client.

  4. 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']
200 — extrait
{
  "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']
200
{
  "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']
201 Created
{
  "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.

Un 201 ne veut pas dire « payé »

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

ChampRequisDescription
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é.

Webhook reçu — payment.succeeded
{
  "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.

Cas limite

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 :

422 — OTP manquant
{
  "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.

Requête — carte
{
  "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 :

201 — extrait
{
  "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}.

Champs obligatoires en carte

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ômeCause
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.