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
-
Vous créez le plan
POST /installment-plansfixe le montant cible (target_amount). Le plan naîtpending. -
Le client verse, en plusieurs fois
Chaque appel à
POST /installment-plans/{reference}/paymentsencaisse un versement et le place dans un portefeuille de séquestre dédié au plan — pas dans votre solde. -
MivaaPay libère les fonds à la complétion
Dès que la somme des versements atteint
target_amount, le plan passesucceededet le séquestre est transféré vers votre solde MivaaPay, en une seule fois.
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 plan | Ce 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']
{
"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"
}
}
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
| Champ | Requis | Description |
|---|---|---|
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.
| Statut | Signification |
|---|---|
pending | Le plan est actif et accepte des versements, ou son annulation est en cours de décaissement. |
succeeded | Le plan est complété : paid_amount a atteint target_amount, les fonds sont sur votre solde. |
refunded | Le 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é. |
failed | Le 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 :
{
"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é :
{
"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)."]
}
}
}
{
"success": false,
"error": {
"code": "invalid_request",
"message": "Ce versement dépasse le solde restant du plan.",
"details": {
"amount": ["Solde restant : 100000 XOF."]
}
}
}
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']
{
"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é).
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.
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).
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.
curl https://api.mivaapay.com/api/v1/installment-plans/3d8f21e0-9c4b-4a17-8e02-7f5c9a1b6d43 \
-H "Authorization: Bearer $MIVAAPAY_SECRET_KEY"
{
"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
| HTTP | Code | Cause |
|---|---|---|
| 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.