Encaissez sur votre propre site avec Zepargn Business : votre serveur crée le paiement, votre client paie par Mobile Money sur la page Zepargn, puis revient chez vous.
Base de l’API : https://prodapi.zepargn.com/v2/api/merchant/v1. Réponses en JSON, montants en FCFA entiers.
1. Démarrer
- Depuis Zepargn Business, ouvrez Paramètres puis Intégrations, et créez une clé de test (
zp_test_…). Elle fonctionne immédiatement, sans dossier KYB ni activation. - Intégrez et testez : les paiements de test se « simulent », aucun argent ne circule.
- Pour encaisser réellement : dossier KYB approuvé, numéro MTN de reversement renseigné, accès API ouvert par Zepargn, puis créez une clé réelle (
zp_live_…).
La clé n’est affichée qu’une fois, à sa création. C’est un secret serveur : jamais dans une page web, une application mobile ou un dépôt public. Cinq clés actives par mode. Révocation immédiate depuis le même écran.
2. Authentification
Authorization: Bearer zp_live_0123456789abcdef0123456789abcdef01234567ou X-Api-Key: zp_live_….
Portées d’une clé :
payments:write: créer, encaisser, annuler, rembourser ;payments:read: lire, lister, solde, événements ;webhooks:manage: gérer les webhooks par l’API, jamais accordée par défaut.
| Code | Statut | Sens |
|---|---|---|
| MERCHANT_API_KEY_MISSING | 401 | aucune clé |
| MERCHANT_API_KEY_INVALID | 401 | clé inconnue ou révoquée |
| MERCHANT_API_DISABLED | 403 | accès réel non ouvert pour l’entreprise |
| MERCHANT_SCOPE_INSUFFICIENT | 403 | portée manquante (required) |
| RATE_LIMITED | 429 | plus de 300 requêtes par minute sur la clé |
Vérifiez votre clé : GET /me renvoie { livemode, business: { id, name, currency, kybStatus, kybLevel, limits, allowedReturnDomains }, apiKey: { id, name, prefix, scopes } }.
Mode test et mode réel. Le mode vient de la clé. Une clé de test ne voit que les paiements de test, une clé réelle que les paiements réels. Chaque paiement et chaque webhook porte livemode: true | false.
3. Créer un paiement
POST /payments, portée payments:write.
{
"amount": 5000,
"description": "Commande #1042",
"reference": "CMD-1042",
"customer": { "name": "Awa K.", "phone": "+2290197111111", "email": "[email protected]" },
"returnUrl": "https://shop.example.com/retour?order=1042",
"cancelUrl": "https://shop.example.com/panier",
"metadata": { "orderId": "1042", "channel": "web" },
"expiresInMinutes": 30,
"idempotencyKey": "cmd-1042-v1"
}| Champ | Règle |
|---|---|
| amount | entier en FCFA, minimum 100, soumis aux plafonds de votre niveau KYB |
| description | 200 caractères au plus, affichée au payeur |
| reference | 60 caractères au plus : votre numéro de commande, affiché au payeur et rendu dans le webhook |
| customer | facultatif ; phone préremplit le paiement MoMo |
| returnUrl, cancelUrl | https public, 500 caractères au plus, ni IP ni hôte privé ; si votre entreprise a déclaré des domaines autorisés, l’URL doit en faire partie |
| metadata | objet plat, 20 clés au plus, clés [A-Za-z0-9_-], valeurs texte, nombre ou booléen, 2 Ko au plus ; rendu tel quel dans le webhook |
| expiresInMinutes | 5 à 43200 (30 jours), 7 jours par défaut |
| idempotencyKey | ou en-tête Idempotency-Key, 100 caractères au plus |
Réponse 201 (ou 200 avec replayed: true au rejeu de la même clé d’idempotence) :
{ "success": true, "replayed": false,
"payment": {
"id": "6650…", "token": "k3Jd…", "link": "https://zepargn.com/pay/k3Jd…",
"status": "pending", "livemode": true,
"amount": 5000, "feeAmount": 150, "feeRate": 3, "customerTotal": 5150, "currency": "XOF",
"description": "Commande #1042", "reference": "CMD-1042",
"metadata": { "orderId": "1042", "channel": "web" },
"returnUrl": "https://shop.example.com/retour?order=1042",
"cancelUrl": "https://shop.example.com/panier",
"payerPhone": "+2290197111111", "paidAt": null, "collectionRef": null,
"refundedAmount": 0, "refundableAmount": 0, "lastRefundAt": null,
"expiresAt": "2026-11-09T10:30:00.000Z", "createdAt": "2026-11-09T10:00:00.000Z" } }customerTotal est ce que paie le client, frais inclus. amount est ce que vous touchez. Redirigez le client vers payment.link.
Statuts : pending (en attente), paid (encaissé), cancelled, expired.
Avant de créer : GET /fees?amount=5000 renvoie { feeRate, feeAmount, customerTotal, limits, usedToday, remainingToday, allowed, reason }, qui donne le total exact et dit si la création passerait à cet instant.
4. Retour du client sur votre site
Après paiement, la page Zepargn renvoie le client vers returnUrl en ajoutant à sa query string :
?zp_token=k3Jd…&zp_status=paidS’il renonce, vers cancelUrl avec zp_status=cancelled.
Ne validez jamais une commande sur ce seul retour : il vient du navigateur du client. Faites foi au webhook payment.succeeded ou à GET /payments/:id.
Pour encaisser sans quitter votre site : POST /payments/:id/charge { "phone": "+22901…" } pousse la demande de code PIN MTN sur le téléphone du client et répond 202 { status: "pending", collection: { provider: "mtn", referenceId }, payment }. Le résultat arrive par webhook ou par GET /payments/:id. En mode test : 200 { status: "paid", simulated: true }.
5. Lire, lister, annuler
GET /payments/:idOuTokenrenvoie{ payment }. Un paiement d’une autre entreprise ou de l’autre mode : 404.GET /payments?status=&from=&to=&page=&limit=(100 au plus) renvoie{ payments, total, page, limit }.POST /payments/:id/cancelrenvoie{ payment }encancelled, sinon 409ALREADY_PAID_OR_CANCELLED.
6. Webhooks
Déclarez une URL https dans Zepargn Business (Intégrations, onglet Webhooks) ou par l’API (portéewebhooks:manage). Vous recevez un secret whsec_…, affiché une fois. Cinq URL au plus.
| Événement | Quand |
|---|---|
| payment.succeeded | le paiement est encaissé (status: "paid") |
| payment.failed | une tentative MoMo a échoué ; le lien reste payable (data.failureReason) |
| payment.refunded | un remboursement a été viré au client (data.refund) |
| ping | test depuis Zepargn Business ou POST /webhooks/:id/test |
Requête : POST sur votre URL, Content-Type: application/json, délai 10 s.
X-Zepargn-Event: payment.succeeded
X-Zepargn-Event-Id: 2c1c9e2e-…
X-Zepargn-Timestamp: 1762682400
X-Zepargn-Signature: v1=3f7a…Corps :
{ "id": "2c1c9e2e-…", "type": "payment.succeeded", "createdAt": "2026-11-09T10:05:12.000Z",
"businessId": "…", "livemode": true,
"data": { "…même forme que payment, avec status paid, paidAt, collectionRef, payerPhone" } }Vérifier la signature
X-Zepargn-Signature vaut v1= suivi de HMAC-SHA256(secret, timestamp + "." + corpsBrut) en hexadécimal, où timestamp est la valeur de X-Zepargn-Timestamp (secondes Unix) etcorpsBrut le corps exactement tel que reçu, avant tout parsing.
const crypto = require('crypto');
function verifyZepargn(rawBody, headers, secret) {
const ts = headers['x-zepargn-timestamp'];
const sig = String(headers['x-zepargn-signature'] || '').replace(/^v1=/, '');
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 min
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return sig.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
}function verifyZepargn(string $rawBody, array $headers, string $secret): bool {
$ts = $headers['X-Zepargn-Timestamp'] ?? '';
$sig = preg_replace('/^v1=/', '', $headers['X-Zepargn-Signature'] ?? '');
if (abs(time() - (int)$ts) > 300) return false;
$expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $sig);
}Avec Express, lisez le corps brut (express.raw({ type: 'application/json' })) sur la route du webhook : un corps re-sérialisé ne donne pas la même signature.
Nouveaux essais
Répondez 2xx en moins de 10 s. Toute autre réponse, ou un délai dépassé, déclenche un nouvel essai, huit au total, avec un délai qui double à partir de 30 s : 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, 32 min (environ 2 h au total). Au-delà, la livraison est marquée échouée et reste visible dans Intégrations, onglet Webhooks, bouton Livraisons.
Un même événement a le même id sur toutes vos URL et peut vous parvenir deux fois : traitez-le de façon idempotente (mémorisez les id traités).
Rattrapage
Votre serveur était indisponible ? GET /events?since=2026-11-09T00:00:00Z&type=payment.succeeded donne la liste des événements émis (30 jours d’historique), avec pour chacun l’état de livraison vers chaque URL. POST /webhooks/deliveries/:id/retry (portée webhooks:manage) remet une livraison en file. Le journal ne contient que ce qui a été émis vers au moins une URL : sans webhook déclaré, utilisezGET /payments?status=paid&from=….
Gérer les webhooks par l’API (portée webhooks:manage)
POST /webhooks { url, events, description? }renvoie 201{ endpoint, secret }GET /webhooks,DELETE /webhooks/:idPOST /webhooks/:id/rotate-secretrenvoie un nouveau secretPOST /webhooks/:id/testrenvoie{ delivered, statusCode }GET /webhooks/:id/deliveries?limit=50
7. Remboursements
POST /payments/:id/refunds { amount?, reason?, idempotencyKey? }, portée payments:write. Sans amount, tout ce qui reste remboursable est remboursé.
- Le paiement doit être
paid. Remboursable jusqu’àcustomerTotal(ce que le client a payé, frais compris). Les frais Zepargn ne sont pas restitués : vous financez la totalité du remboursement. Minimum 100 FCFA. - Le remboursement est viré au numéro qui a payé, et prélevé sur vos fonds retenus chez Zepargn (
GET /balance, champavailable). Si le paiement a déjà été reversé sur votre MoMo et que rien n’est retenu : 409REFUND_FUNDS_UNAVAILABLE { available, requested }. Zepargn ne prélève jamais votre compte MoMo. - Un seul remboursement en cours par paiement.
Réponse 201 (200 au rejeu) :
{ "success": true, "replayed": false,
"refund": { "id": "…", "paymentId": "…", "amount": 5150, "currency": "XOF", "status": "sent",
"reason": "Commande annulée", "toPhone": "+229******1111", "providerRef": "…",
"livemode": true, "createdAt": "…", "sentAt": "…" },
"payment": { "…", "refundedAmount": 5150, "refundableAmount": 0 } }status : sent (viré), manual_review (virement échoué, vos fonds sont engagés, le support Zepargn termine et vous prévient), failed. Webhook payment.refunded.
Lecture : GET /payments/:id/refunds, GET /refunds/:refundId, GET /refunds?page&limit.
8. Solde et reversements
GET /balancerenvoie{ currency, livemode, held, engaged, available, collectedToday: { count, gross, net, fees }, pendingPayoutCount, payoutNumber }.GET /payouts?from=&to=&page=&limit=: vos reversements, un par référence :{ id, type: "automatic" | "withdrawal" | "refund", amount, sentAt, toPhone, paymentIds, paymentCount }.GET /payouts/:id: le détail avec la liste des paiements couverts.
9. Mode test
- Clé
zp_test_…, créée sans activation ni KYB. Même API,livemode: false. - Le lien de paiement ouvre la page Zepargn avec les boutons « Simuler un paiement réussi » et « Simuler un échec ». Depuis votre serveur :
POST /payments/:id/chargepasse le paiement enpaidimmédiatement ;POST https://prodapi.zepargn.com/v2/api/pay/:token/simulate { "outcome": "success" | "failure" }fonctionne sans clé. - Les webhooks partent normalement avec
livemode: false. Rien n’est reversé, rien n’entre dans vos plafonds, vos statistiques ni votre solde. - Les remboursements de test sont immédiats.
10. Codes d’erreur
Toute erreur a la forme { "success": false, "errorCode": "…", "message": "…", "messageEn": "…", …détails }.
| Code | Statut | Quand |
|---|---|---|
| AMOUNT_INVALID | 400 | montant non entier ou inférieur à 100 |
| AMOUNT_ABOVE_LIMIT | 400 | plafond par transaction de votre niveau KYB (limit, requested, kybLevel) |
| DAILY_LIMIT_REACHED | 429 | plafond journalier atteint (remaining) |
| BUSINESS_KYB_REQUIRED | 403 | dossier KYB non approuvé (live) |
| PAYOUT_NUMBER_MISSING | 400 | aucun numéro MTN de reversement (live) |
| RETURN_URL_INVALID, CANCEL_URL_INVALID | 400 | URL non https, non publique ou trop longue |
| RETURN_URL_DOMAIN_NOT_ALLOWED | 400 | domaine hors de votre liste autorisée (field, allowed) |
| METADATA_INVALID | 400 | forme ou taille de metadata |
| REFERENCE_INVALID, DESCRIPTION_INVALID, CUSTOMER_INVALID | 400 | champ trop long ou mal formé |
| EXPIRES_IN_INVALID | 400 | expiresInMinutes hors de 5 à 43200 |
| IDEMPOTENCY_KEY_INVALID | 400 | clé vide ou trop longue |
| IDEMPOTENCY_CONFLICT | 409 | même clé, montant ou référence différents (paymentId ou refundId d’origine) |
| RATE_LIMITED | 429 | 300 requêtes par minute par clé, ou 20 créations par heure par entreprise en live |
| PAYMENT_NOT_FOUND | 404 | inconnu, autre entreprise ou autre mode |
| ALREADY_PAID_OR_CANCELLED | 409 | annulation ou encaissement impossible |
| PAYER_PHONE_REQUIRED, PAYER_PHONE_INVALID, PAYER_PHONE_NOT_MTN | 400 | encaissement direct |
| COLLECTION_IN_FLIGHT | 409 | une demande MoMo est déjà en cours sur ce paiement |
| COLLECTION_FAILED | 502 | l’opérateur n’a pas accepté la demande |
| REFUND_PAYMENT_NOT_PAID | 409 | le paiement n’est pas paid |
| REFUND_AMOUNT_INVALID | 400 | montant sous 100 (min, refundable) |
| REFUND_AMOUNT_EXCEEDS | 409 | plus que le remboursable (refundable, requested) |
| REFUND_PAYER_UNKNOWN | 409 | aucun numéro de payeur connu |
| REFUND_FUNDS_UNAVAILABLE | 409 | fonds retenus insuffisants (available, requested) |
| REFUND_IN_PROGRESS | 409 | un remboursement est déjà en cours (refundId) |
| REFUND_NOT_FOUND | 404 | inconnu ou autre entreprise |
| WEBHOOK_URL_INVALID | 400 | URL non https ou non publique (reason) |
| WEBHOOK_EVENTS_INVALID | 400 | événement inconnu (allowed) |
| WEBHOOK_LIMIT | 409 | cinq URL déjà déclarées |
| WEBHOOK_NOT_FOUND, DELIVERY_NOT_FOUND | 404 | inconnu ou autre entreprise |
| DELIVERY_ALREADY_PENDING | 409 | la livraison est déjà en file |
| PAYOUT_NOT_FOUND | 404 | référence inconnue |
11. Flux complet
- Le client valide son panier sur votre site.
- Votre serveur appelle
POST /paymentsavec votre numéro de commande enreferenceet enIdempotency-Key. - Vous redirigez le client vers
payment.link. - Il paie par MoMo ou Kkiapay, puis revient sur
returnUrlaveczp_tokenetzp_status. - Votre serveur reçoit
payment.succeeded, vérifie la signature, retrouve la commande pardata.referenceoudata.metadata, et la valide. - Le reversement vers votre numéro MTN suit les règles habituelles de Zepargn Business.
Une question sur l’intégration : [email protected].
