Docs API

Guides

Paiement échelonné

Un plan de paiement échelonné laisse votre client régler un montant en plusieurs versements — Mobile Money ou carte, comme un paiement normal. MivaaPay séquestre chaque versement et ne le crédite à votre solde qu'une fois le plan intégralement payé : vous restez un simple facilitateur, jamais dépositaire d'un argent que le client n'a pas fini de vous devoir.

Comment ça marche

  1. Vous créez le plan

    POST /installment-plans fixe le montant cible (target_amount). Le plan naît pending.

  2. Le client verse, en plusieurs fois

    Chaque appel à POST /installment-plans/{reference}/payments encaisse un versement et le place dans un portefeuille de séquestre dédié au plan — pas dans votre solde.

  3. MivaaPay libère les fonds à la complétion

    Dès que la somme des versements atteint target_amount, le plan passe succeeded et le séquestre est transféré vers votre solde MivaaPay, en une seule fois.

MivaaPay reste un simple facilitateur

Vous ne touchez rien avant la complétion du plan, et vous ne décidez ni des frais de création ni de la pénalité d'annulation — voir plus bas. Votre seule prise sur le plan : en fixer le montant cible à la création, et pouvoir l'annuler s'il ne va pas à son terme.

Frais et pénalité : des réglages plateforme, pas les vôtres

Deux montants s'appliquent à chaque plan, et aucun des deux n'est de votre ressort : ils sont configurés côté MivaaPay, valables pour tous les marchands, et figés sur le plan au moment de sa création — un changement de réglage ultérieur ne rejoue jamais sur un plan déjà en cours.

Champ sur le planCe que c'est
creation_fee_amount Des frais de création, prélevés sur le premier versement uniquement, en plus de votre target_amount. La plateforme choisit un montant fixe ou un pourcentage de target_amount — jamais les deux à la fois.
cancellation_penalty_percent Un pourcentage retenu sur ce que le client a déjà versé, en cas d'annulation ou d'expiration du plan — voir Annuler un plan.

Les deux valeurs sont renvoyées sur chaque plan pour que vous puissiez les afficher au client avant qu'il ne s'engage : elles ne changent jamais après coup pour un plan donné, même si le réglage plateforme évolue entre-temps.

Créer un plan

curl -X POST https://api.mivaapay.com/api/v1/installment-plans \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_amount": 150000,
    "description": "Smartphone Galaxy A54 — paiement en 3 fois",
    "external_id": "plan_cmd_2874"
  }'
$plan = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
    ->post('https://api.mivaapay.com/api/v1/installment-plans', [
        'target_amount' => 150000,
        'description'   => 'Smartphone Galaxy A54 — paiement en 3 fois',
        'external_id'   => 'plan_cmd_2874',
    ])
    ->json('data');
const res = await fetch('https://api.mivaapay.com/api/v1/installment-plans', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    target_amount: 150000,
    description: 'Smartphone Galaxy A54 — paiement en 3 fois',
    external_id: 'plan_cmd_2874'
  })
});

const { data: plan } = await res.json();
plan = requests.post(
    'https://api.mivaapay.com/api/v1/installment-plans',
    json={
        'target_amount': 150000,
        'description': 'Smartphone Galaxy A54 — paiement en 3 fois',
        'external_id': 'plan_cmd_2874',
    },
    headers={'Authorization': f"Bearer {SECRET_KEY}"},
    timeout=30,
).json()['data']
201 Created
{
  "success": true,
  "data": {
    "reference": "3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43",
    "external_id": "plan_cmd_2874",
    "status": "pending",
    "description": "Smartphone Galaxy A54 — paiement en 3 fois",
    "currency": "XOF",
    "target_amount": 150000,
    "creation_fee_amount": 1000,
    "cancellation_penalty_percent": 5,
    "paid_amount": 0,
    "remaining_amount": 150000,
    "cancellation_reason": null,
    "livemode": true,
    "expires_at": "2026-11-20T09:12:44+00:00",
    "completed_at": null,
    "cancelled_at": null,
    "created_at": "2026-09-21T09:12:44+00:00"
  }
}
Pas de payments à la création

Le tableau des versements n'apparaît que sur GET /installment-plans/{reference}, détaillé plus bas : à la création, il n'y en a de toute façon aucun.

Paramètres

ChampRequisDescription
target_amount oui Montant cible du plan, hors frais de création. Entre 1 et 99 999 999.
currency non XOF (défaut), XAF, EUR ou USD.
description non Libellé libre, pour vos rapprochements. 255 caractères maximum.
external_id non Votre identifiant. Rend l'appel idempotent : rejouer le même external_id renvoie le plan existant au lieu d'en créer un second. Unique par compte et par environnement, comme pour les paiements et les retraits.

Statuts d'un plan

Même vocabulaire à quatre valeurs que partout ailleurs sur l'API.

StatutSignification
pendingLe plan est actif et accepte des versements, ou son annulation est en cours de décaissement.
succeededLe plan est complété : paid_amount a atteint target_amount, les fonds sont sur votre solde.
refundedLe plan a été annulé (par vous, ou par expiration) et le client a été remboursé de ce qu'il avait versé, moins la pénalité.
failedLe remboursement d'annulation lui-même a échoué. Cas rare, à rapprocher manuellement — contactez le support.

Enregistrer un versement

Le corps est celui de POST /payments, moins les champs propres au plan : pas de description ni d'external_id pour un versement, le plan porte déjà les siens. Le comportement est identique à un paiement normal — Mobile Money débite le client, carte renvoie une payment_url vers laquelle le rediriger — reportez-vous à cette page pour l'OTP, les opérateurs et le suivi.

curl -X POST https://api.mivaapay.com/api/v1/installment-plans/3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43/payments \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50000,
    "operator": "mtn",
    "phone_number": "22997000000"
  }'
$payment = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
    ->post("https://api.mivaapay.com/api/v1/installment-plans/{$reference}/payments", [
        'amount'       => 50000,
        'operator'     => 'mtn',
        'phone_number' => '22997000000',
    ])
    ->json('data');
const res = await fetch(
  `https://api.mivaapay.com/api/v1/installment-plans/${reference}/payments`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ amount: 50000, operator: 'mtn', phone_number: '22997000000' })
  }
);

const { data: payment } = await res.json();
payment = requests.post(
    f'https://api.mivaapay.com/api/v1/installment-plans/{reference}/payments',
    json={'amount': 50000, 'operator': 'mtn', 'phone_number': '22997000000'},
    headers={'Authorization': f"Bearer {SECRET_KEY}"},
    timeout=30,
).json()['data']

La réponse est un objet paiement, pas un objet plan :

201 Created
{
  "success": true,
  "data": {
    "reference": "b2b4b0c1-9f3e-4a2d-8c71-5e0a9d3f7c12",
    "identifier": "Id3390",
    "external_id": null,
    "status": "pending",
    "amount": 50000,
    "fee_amount": 900,
    "total_amount": 50900,
    "currency": "XOF",
    "description": "Versement plan 3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43",
    "payment_mode": {
      "id": 1,
      "name": "MTN Bénin",
      "type": "mobile_money",
      "operator": "mtn"
    },
    "customer": {
      "firstname": null,
      "lastname": null,
      "email": null,
      "phone_number": "22997000000"
    },
    "payment_url": null,
    "metadata": null,
    "created_at": "2026-09-21T09:14:02+00:00",
    "updated_at": "2026-09-21T09:14:02+00:00"
  }
}

Deux règles, propres au versement, sont vérifiées avant d'engager le paiement chez l'opérateur — le client n'est jamais débité pour un versement refusé :

422 — premier versement trop faible
{
  "success": false,
  "error": {
    "code": "invalid_request",
    "message": "Le premier versement doit couvrir au moins les frais de création du plan.",
    "details": {
      "amount": ["Minimum 1000 XOF (frais de création inclus)."]
    }
  }
}
422 — solde restant dépassé
{
  "success": false,
  "error": {
    "code": "invalid_request",
    "message": "Ce versement dépasse le solde restant du plan.",
    "details": {
      "amount": ["Solde restant : 100000 XOF."]
    }
  }
}
Les frais de création ne sortent que du premier versement

Sur le premier versement, la part qui compte pour paid_amount est amount moins creation_fee_amount : c'est pour ça qu'il doit au minimum couvrir ces frais. Les versements suivants comptent en entier.

Annuler un plan

Un plan incomplet peut être annulé à tout moment : par vous, via cet appel, ou automatiquement par MivaaPay à l'expiration du plan (60 jours après sa création par défaut, non paramétrable par le marchand). Dans les deux cas, le client est remboursé de ce qu'il a versé, moins la pénalité d'annulation figée sur le plan.

curl -X POST https://api.mivaapay.com/api/v1/installment-plans/3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43/cancel \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Client injoignable, versements interrompus" }'
$plan = Http::withToken(env('MIVAAPAY_SECRET_KEY'))
    ->post("https://api.mivaapay.com/api/v1/installment-plans/{$reference}/cancel", [
        'reason' => 'Client injoignable, versements interrompus',
    ])
    ->json('data');
const res = await fetch(
  `https://api.mivaapay.com/api/v1/installment-plans/${reference}/cancel`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MIVAAPAY_SECRET_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ reason: 'Client injoignable, versements interrompus' })
  }
);

const { data: plan } = await res.json();
plan = requests.post(
    f'https://api.mivaapay.com/api/v1/installment-plans/{reference}/cancel',
    json={'reason': 'Client injoignable, versements interrompus'},
    headers={'Authorization': f"Bearer {SECRET_KEY}"},
    timeout=30,
).json()['data']
200
{
  "success": true,
  "data": {
    "reference": "3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43",
    "external_id": "plan_cmd_2874",
    "status": "pending",
    "description": "Smartphone Galaxy A54 — paiement en 3 fois",
    "currency": "XOF",
    "target_amount": 150000,
    "creation_fee_amount": 1000,
    "cancellation_penalty_percent": 5,
    "paid_amount": 50000,
    "remaining_amount": 100000,
    "cancellation_reason": "Client injoignable, versements interrompus",
    "livemode": true,
    "expires_at": "2026-11-20T09:12:44+00:00",
    "completed_at": null,
    "cancelled_at": null,
    "created_at": "2026-09-21T09:12:44+00:00"
  }
}

reason est optionnel. Le statut renvoyé reste pending : comme un remboursement, le décaissement n'est pas instantané. Le plan passe à refunded une fois le client recrédité — interrogez GET /installment-plans/{reference} pour le constater, il n'y a pas de webhook dédié (voir Être notifié).

Le remboursement d'annulation suit les mêmes limites qu'un remboursement classique

Seul un versement Mobile Money est automatiquement remboursable. Si le dernier versement validé du plan a été payé par carte, ou si le décaissement échoue à l'initiation, le plan passe failed : contactez le support pour un traitement manuel.

Un plan déjà soldé ne peut pas être annulé

Annuler un plan succeeded, refunded ou déjà failed renvoie un conflict.

Lister vos plans

Paginé, comme les autres listes de l'API ; per_page va de 1 à 100 (25 par défaut).

Lister les plans
curl "https://api.mivaapay.com/api/v1/installment-plans?per_page=50" \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"

Consulter un plan

La reference dans l'URL accepte aussi bien la référence du plan que votre propre external_id : pratique pour retrouver un plan sans avoir stocké la référence MivaaPay. C'est le seul endpoint qui renvoie le détail des versements, dans payments — chaque élément a exactement la forme d'un objet paiement, comme celui renvoyé par POST /installment-plans/{reference}/payments ci-dessus.

Consulter un plan
curl https://api.mivaapay.com/api/v1/installment-plans/3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43 \
  -H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"
200
{
  "success": true,
  "data": {
    "reference": "3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43",
    "external_id": "plan_cmd_2874",
    "status": "pending",
    "description": "Smartphone Galaxy A54 — paiement en 3 fois",
    "currency": "XOF",
    "target_amount": 150000,
    "creation_fee_amount": 1000,
    "cancellation_penalty_percent": 5,
    "paid_amount": 50000,
    "remaining_amount": 100000,
    "cancellation_reason": null,
    "livemode": true,
    "expires_at": "2026-11-20T09:12:44+00:00",
    "completed_at": null,
    "cancelled_at": null,
    "created_at": "2026-09-21T09:12:44+00:00",
    "payments": [
      {
        "reference": "b2b4b0c1-9f3e-4a2d-8c71-5e0a9d3f7c12",
        "identifier": "Id3390",
        "external_id": null,
        "status": "succeeded",
        "amount": 50000,
        "fee_amount": 900,
        "total_amount": 50900,
        "currency": "XOF",
        "description": "Versement plan 3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43",
        "payment_mode": { "id": 1, "name": "MTN Bénin", "type": "mobile_money", "operator": "mtn" },
        "customer": { "firstname": null, "lastname": null, "email": null, "phone_number": "22997000000" },
        "payment_url": null,
        "metadata": null,
        "created_at": "2026-09-21T09:14:02+00:00",
        "updated_at": "2026-09-21T09:20:11+00:00"
      }
    ]
  }
}

Être notifié

Un versement déclenche les mêmes webhooks qu'un paiement normal — payment.succeeded, payment.failed — signés et journalisés à l'identique. Il n'existe pas d'évènement dédié à la clôture ou à l'annulation du plan lui-même : pour savoir quand un plan devient succeeded ou refunded, interrogez GET /installment-plans/{reference} après chaque versement, ou après avoir demandé une annulation.

Erreurs possibles

HTTPCodeCause
404 not_found Aucun plan de votre compte ne porte cette référence (ni cet external_id).
409 conflict Le plan n'est plus pending actif : un versement ou une annulation est refusé sur un plan déjà soldé.
422 invalid_request Montant hors bornes, premier versement sous les frais de création, ou versement excédant le solde restant.

Voir Erreurs pour l'enveloppe commune et les codes partagés avec le reste de l'API.

Essayer sans risque

Un plan créé avec une clé sk_test_… se comporte exactement comme en production, à ceci près qu'aucun argent ne bouge : chaque versement suit les règles du bac à sable décrites dans Mode test (issue déterminée par les quatre derniers chiffres du numéro), y compris pour le remboursement d'une annulation. Un test key ne voit que des plans de test, comme pour toute autre ressource de l'API.