API de création de lien de paiement
Crée un lien de paiement pour votre boutique. Vous redirigez le client vers le paymentLink retourné, et Papi notifie votre notificationUrl lorsque le statut du paiement change.
Pour un parcours pas à pas de l'ensemble du flux de paiement, consultez le guide d'intégration.
Endpoint
POST https://app.papi.mg/engine/api/payment-links
| Élément | Valeur |
|---|---|
| URL de base | https://app.papi.mg/engine/api |
| Chemin | /payment-links |
| Méthode | POST |
| Corps de la requête | JSON |
| Corps de la réponse | JSON |
POST https://app.papi.mg/dashboard/api/payment-links est toujours accepté pour les intégrations existantes. Il transmet la requête au moteur sans la modifier et renvoie la réponse du moteur sans la modifier : le comportement décrit sur cette page est donc le même sur les deux URLs. Seule différence : si le moteur est injoignable, l'URL historique répond 503 avec le code ENGINE_UNAVAILABLE. Utilisez l'URL du moteur pour les nouvelles intégrations.
Authentification
Chaque requête doit porter la clé API de votre boutique dans l'en-tête Token. Vous trouvez la clé API dans le tableau de bord : Icône avatar → Boutiques → sélectionnez la boutique → onglet Développeur.
Le lien est créé dans la boutique à laquelle appartient la clé API. L'idempotence (voir Idempotence) est également évaluée dans cette boutique.
Sur cet endpoint, une clé API absente ou inconnue reçoit une réponse 400 (et non 401). Voir Erreurs.
En-têtes de la requête
| En-tête | Requis | Valeur | Description |
|---|---|---|---|
Token | ✓ | <YOUR_API_KEY> | Clé API de votre boutique. |
Content-Type | ✓ | application/json | Le corps est en JSON. L'URL historique du tableau de bord refuse tout autre type de contenu. |
Aucun autre en-tête n'est lu par cet endpoint. En particulier, l'en-tête LinkToken utilisé par la page de paiement n'est pas accepté ici comme méthode d'authentification. Le mode test se définit dans le corps avec isTestMode, pas avec un en-tête.
Paramètres du corps
| Champ | Type | Requis | Contraintes / validation | Description |
|---|---|---|---|---|
amount | number | ✓ | Non nul. Minimum 300. | Montant à payer, en currency. |
reference | string | ✓ | Non vide. | Votre identifiant pour ce paiement (par exemple, votre ID de commande). C'est la clé d'idempotence : voir Idempotence. Elle est retournée comme paymentReference dans la réponse de création et comme merchantPaymentReference dans les notifications et dans la relecture. |
clientName | string | ✓ | Non vide. | Nom du client. |
description | string | ✓ | Non vide. 255 caractères maximum. | Description courte du paiement. |
successUrl | string | ✗ | Doit commencer par http:// ou https://. Doit être envoyé avec failureUrl. | URL vers laquelle le client est redirigé après un paiement réussi. |
failureUrl | string | ✗ | Doit commencer par http:// ou https://. Doit être envoyé avec successUrl. | URL vers laquelle le client est redirigé après un paiement échoué. |
notificationUrl | string | ✗ | Doit commencer par http:// ou https://. | Votre endpoint qui reçoit les notifications de paiement. Fortement recommandé. Sans lui, lisez l'issue avec l'API de statut de lien de paiement. |
validDuration | integer | ✗ | Entier supérieur à 0, au plus 596. Défaut : 1. | Validité du lien, en heures, à compter de sa création (596 heures représentent environ 24 jours). |
provider | string | ✗ | L'une des valeurs MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED. | Restreint le lien à un seul prestataire. S'il est omis, le client choisit sur la page de paiement. |
currency | string | ✗ | Un code de devise connu de Papi (de type ISO 4217, en majuscules). Défaut : MGA. | Devise de amount. Seul MGA est réglé aujourd'hui : envoyez MGA ou omettez le champ. Un autre code connu est accepté par cet endpoint, mais Papi ne règle pas de paiements dans cette devise. |
displayCurrency | string | ✗ | Seul MGA est accepté. Défaut : MGA. | Devise affichée au client sur la page de paiement. |
payerEmail | string | ✗ | Adresse email valide. Une chaîne vide est traitée comme une absence. | Adresse email du client. |
payerPhone | string | ✗ | Numéro de téléphone valide. Caractères autorisés : chiffres, +, -, espace, (, ), .. Les numéros sans indicatif pays sont lus comme des numéros malgaches. Une chaîne vide est traitée comme une absence. | Numéro de téléphone du client. Il est retourné au format international (par exemple +261 34 00 000 00). |
isTestMode | boolean | ✗ | Défaut : false. | Définir à true pour marquer le lien comme test. Voir Mode test. |
testReason | string | ✗ | Aucune. | Raison du test. Affichée dans le tableau de bord. |
paymentTester | string | ✗ | L'une des valeurs PAPI_DEV, PAPI_TEST, MERCHANT_DEV, MERCHANT, EXTERNAL_DEV (insensible à la casse). | Qui effectue le test. Enregistré avec le lien. |
linkState | boolean | ✗ | Aucune. | Accepté pour compatibilité et ignoré : un lien créé via l'API est toujours activé. |
Tout autre champ du corps est ignoré.
Exemple de requête
curl -X POST "https://app.papi.mg/engine/api/payment-links" \
-H "Content-Type: application/json" \
-H "Token: <YOUR_API_KEY>" \
-d '{
"amount": 15000,
"reference": "ORDER-123",
"clientName": "Client Name",
"description": "Payment for Order #123",
"successUrl": "https://yourapp.com/payment-success",
"failureUrl": "https://yourapp.com/payment-failure",
"notificationUrl": "https://yourapp.com/payment-notify",
"validDuration": 60,
"provider": "MVOLA",
"payerEmail": "customer@example.com",
"payerPhone": "+261340000000",
"isTestMode": false
}'
Corps de la requête seul :
{
"amount": 15000,
"reference": "ORDER-123",
"clientName": "Client Name",
"description": "Payment for Order #123",
"successUrl": "https://yourapp.com/payment-success",
"failureUrl": "https://yourapp.com/payment-failure",
"notificationUrl": "https://yourapp.com/payment-notify",
"validDuration": 60,
"provider": "MVOLA",
"payerEmail": "customer@example.com",
"payerPhone": "+261340000000",
"isTestMode": false
}
Réponse en cas de succès
Statut : 200 OK. Le même statut est retourné lorsqu'un lien en vigueur existant est renvoyé (voir Idempotence).
{
"data": {
"amount": 15000.0,
"currency": "MGA",
"displayCurrency": "MGA",
"linkCreationDateTime": 1788065989011,
"linkExpirationDateTime": 1788281989011,
"paymentLink": "https://payment-form.papi.mg/yourshop/payments/eyJhbGciOiJIUzI1NiJ9...",
"clientName": "Client Name",
"paymentReference": "ORDER-123",
"description": "Payment for Order #123",
"successUrl": "https://yourapp.com/payment-success",
"failureUrl": "https://yourapp.com/payment-failure",
"notificationUrl": "https://yourapp.com/payment-notify",
"payerEmail": "customer@example.com",
"payerPhone": "+261 34 00 000 00",
"notificationToken": "5b8f0c3e-2a7d-4f61-9e0b-7c4d1a2e9f38",
"testReason": null,
"isTestMode": false,
"shortLink": "https://link.papi.mg/8NW7R"
}
}
Champs de la réponse
Tous les champs sont présents dans la réponse. Un champ sans valeur vaut null.
| Champ | Type | Description |
|---|---|---|
amount | number | Montant du lien. |
currency | string | Devise de amount (MGA). |
displayCurrency | string | Devise affichée au client (MGA). |
linkCreationDateTime | integer | Date de création, en millisecondes epoch. |
linkExpirationDateTime | integer | Date d'expiration, en millisecondes epoch (linkCreationDateTime + validDuration heures). |
paymentLink | string | L'URL vers laquelle le client doit être redirigé pour payer. Sa forme est https://payment-form.papi.mg/<code-de-votre-application>/payments/<jwt>. Le JWT est émis par Papi et expire avec le lien. Lorsque provider est défini, l'URL se termine par /mobile/<PROVIDER> (par exemple /mobile/MVOLA) ou /card/BRED. |
clientName | string | Nom du client. |
paymentReference | string | Dans cette réponse, c'est votre reference. Ce n'est pas la référence de paiement de Papi. |
description | string | Description du paiement. |
successUrl | string | URL de redirection après succès, ou null. |
failureUrl | string | URL de redirection après échec, ou null. |
notificationUrl | string | URL de notification, ou null. |
payerEmail | string | Email du client, ou null. |
payerPhone | string | Numéro de téléphone du client au format international, ou null. |
notificationToken | string | Jeton généré par Papi pour ce lien (un UUID). Conservez-le : chaque notification pour ce lien porte la même valeur, comme contrôle supplémentaire après la signature. |
testReason | string | Raison du test, ou null. |
isTestMode | boolean | Indique si le lien est marqué comme test. |
shortLink | string | Forme courte de paymentLink. null lorsque le lien court n'a pas pu être généré ; le lien lui-même reste valide. |
reference(requête) =paymentReference(cette réponse) =merchantPaymentReference(notifications et relecture) : votre référence.paymentReference(notifications) =papiPaymentReference(relecture) : la référence de Papi pour une tentative de paiement. Elle n'existe pas encore lorsque le lien est créé.
Idempotence
La création d'un lien de paiement est idempotente sur la reference, dans votre boutique, tant que le lien est en vigueur.
Un lien est en vigueur lorsque toutes les conditions suivantes sont réunies :
- il a été créé via cette API (et non depuis le tableau de bord) ;
- il est activé ;
- il n'est pas payé ;
- sa date d'expiration est dans le futur.
Comportement
Vous envoyez un POST avec une reference qui… | Réponse de Papi |
|---|---|
| …n'a aucun lien en vigueur dans votre boutique | 200 avec un nouveau lien. |
…a un lien en vigueur avec les mêmes amount, currency et displayCurrency | 200 avec le lien existant : même paymentLink, même notificationToken, même linkExpirationDateTime. Aucun nouveau lien n'est créé. |
…a un lien en vigueur avec un amount, une currency ou une displayCurrency différents | 409 avec le code PAYMENT_LINK_CONFLICT. Aucun lien n'est créé. |
| …a servi pour un lien désormais payé, expiré ou désactivé | 200 avec un nouveau lien. L'ancien lien n'est pas modifié. |
Détails
- Seul l'argent est comparé.
amount,currencyetdisplayCurrencydoivent être identiques.amountest comparé comme une valeur exacte :15000et15000.0sont identiques,15000et15000.5sont différents. DescurrencyetdisplayCurrencyomis valentMGA. - Les autres champs du réessai sont ignorés. Lorsque le lien existant est retourné, la réponse décrit le lien tel qu'il a été créé la première fois. Les champs
clientName,description,successUrl,failureUrl,notificationUrl,validDuration,provider,payerEmail,payerPhone,isTestModeettestReasondu réessai ne sont pas appliqués. En particulier, un réessai ne prolonge pas la validité du lien. - La référence est comparée exactement.
ORDER-123etorder-123sont deux références différentes. - La portée est votre boutique. La même
referenceutilisée par une autre boutique n'a aucun effet sur vos requêtes. - Les requêtes simultanées sont sérialisées. Si plusieurs requêtes avec la même
referencearrivent en même temps, la première crée le lien et les autres reçoivent ce même lien (ou un409si leur montant ou leur devise est différent). Un double envoi du checkout ne peut pas créer deux liens payables pour une même commande. - Les liens du tableau de bord sont hors de la règle. Un lien créé à la main depuis le tableau de bord avec la même référence n'est jamais retourné par cet endpoint et n'est jamais une raison de
409. - La validation passe en premier. Une requête qui échoue à la validation (
400) est refusée avant le contrôle d'idempotence. Elle ne retourne jamais un lien existant.
Réponse de conflit
Statut : 409 Conflict
{
"error": {
"code": "PAYMENT_LINK_CONFLICT",
"message": "Un lien de paiement actif existe déjà pour la référence ORDER-123 avec un montant ou une devise différents. Attendez son expiration ou utilisez une autre référence."
}
}
Pour résoudre un conflit, choisissez l'une de ces options :
- attendez l'expiration du lien en vigueur, puis renvoyez la requête ;
- envoyez la requête avec une autre
reference(par exempleORDER-123-2) ; - si vous ne connaissez pas l'état du lien existant, lisez-le avec l'API de statut de lien de paiement.
Erreurs
Toutes les erreurs du tableau ci-dessous utilisent ce format de corps :
{
"error": {
"code": "<ERROR_CODE>",
"message": "<Human-readable message, in French>"
}
}
| Statut HTTP | Code d'erreur | Signification | Exemple de message |
|---|---|---|---|
400 | CORE_INPUT_400 | Un champ du corps a échoué à la validation. Lorsque plusieurs champs échouent, leurs messages sont joints par , sans ordre fixe. | Le montant doit être supérieur ou égal à 300. |
400 | CORE_INPUT_400 | L'en-tête Token est absent, ou la clé API est inconnue. | API key invalide |
400 | CORE_INPUT_400 | displayCurrency n'est pas MGA. | EUR is not supported yet |
400 | CORE_INPUT_400 | currency n'est pas un code de devise connu de Papi (le contrôle est sensible à la casse : mga est refusé). | Unité monétaire invalide |
400 | CORE_INPUT_400 | paymentTester ne fait pas partie des valeurs acceptées. | Invalid value 'SOMEONE |
400 | CORE_INPUT_400 | payerEmail ou payerPhone n'est pas valide. | Invalid email address: not-an-email / Numéro de téléphone invalide |
400 | CORE_INPUT_400 | Le corps n'est pas un JSON valide, ou un champ a un mauvais type (par exemple "amount": "abc"). | Corps de la requête invalide : JSON attendu / Valeur invalide pour le champ 'amount' |
409 | PAYMENT_LINK_CONFLICT | Un lien en vigueur existe déjà pour cette reference avec un montant ou une devise différents. Voir Idempotence. | Un lien de paiement actif existe déjà pour la référence ORDER-123 avec un montant ou une devise différents. Attendez son expiration ou utilisez une autre référence. |
503 | ENGINE_UNAVAILABLE | URL historique du tableau de bord uniquement : le moteur n'a pas pu être joint. | Le service de paiement est momentanément indisponible. Veuillez réessayer. |
Messages de validation
| Champ | Règle | message |
|---|---|---|
amount | Absent | Le montant est requis |
amount | Inférieur à 300 | Le montant doit être supérieur ou égal à 300. |
clientName | Absent ou vide | Le nom du client est réquis |
reference | Absent ou vide | La référence est réquise |
description | Absent ou vide | La description est réquise |
description | Plus de 255 caractères | La description ne doit pas dépasser 255 caractères |
successUrl | Ne commence pas par http:// ou https:// | L'URL de succès doit être valide et commencer par http ou https |
failureUrl | Ne commence pas par http:// ou https:// | L'URL d'échec doit être valide et commencer par http ou https |
successUrl / failureUrl | Un seul des deux est envoyé | Les URLs de succès et d'échec doivent être toutes les deux définies ou toutes les deux absentes |
notificationUrl | Ne commence pas par http:// ou https:// | L'URL de notification doit être valide et commencer par http ou https |
validDuration | 0 ou négatif | La durée de validité doit être supérieure à 0 |
provider | Ne fait pas partie des valeurs acceptées | Valeur invalide pour provider |
payerEmail | Adresse email invalide | Invalid email address: <valeur> |
payerPhone | Numéro de téléphone invalide | Numéro de téléphone invalide |
Exemple d'erreur de validation :
{
"error": {
"code": "CORE_INPUT_400",
"message": "La description est réquise, Le montant doit être supérieur ou égal à 300."
}
}
Pour les champs URL, une chaîne vide n'équivaut pas à un champ absent. "successUrl": "" échoue à la validation. Si vous n'utilisez pas une URL, omettez le champ.
Mode test
- Si votre boutique est une application sandbox, chaque lien qu'elle crée est un lien de test :
isTestModevaut toujourstruedans la réponse, quoi que vous envoyiez. - Si votre boutique est en production, le lien est un lien de test uniquement lorsque vous envoyez
"isTestMode": true. Le lien est marqué comme test dans votre tableau de bord, mais de l'argent réel est quand même déplacé.
Pour des paiements de test par carte sans argent réel, consultez Mode Test dans le guide d'intégration.
Notes pratiques
Réessayer une requête
- Si la requête expire ou si la connexion est coupée, envoyez à nouveau la même requête avec la même
reference. Vous recevez le lien créé par la première requête, ou un nouveau lien si la première requête n'a jamais atteint Papi. Dans les deux cas, un seul lien en vigueur existe. - Réessayez en cas d'erreur réseau et de réponse
5xx. Ne réessayez pas un400sans modifier la requête : le même corps est refusé à nouveau. - Ne réessayez pas un
409avec le même corps. Suivez les options de Réponse de conflit. - Utilisez le
notificationTokende la dernière réponse réussie. Pour un réessai qui a retourné le lien existant, c'est la même valeur que dans la première réponse.
Réémettre un lien pour la même commande
- Le lien est toujours en vigueur et le montant est inchangé : renvoyez la requête. Vous recevez le même lien. Vous pouvez l'envoyer à nouveau au client.
- Le montant de la commande a changé pendant que le lien est en vigueur : vous recevez un
409. Utilisez une nouvellereference, ou attendez l'expiration du lien. - Le lien a expiré ou a été désactivé : renvoyez la requête avec la même
reference. Vous recevez un nouveau lien avec un nouveaunotificationToken. - Le lien a été payé : n'émettez pas de nouveau lien. Avant de réémettre un lien, vérifiez son état avec l'API de statut de lien de paiement. Si vous créez malgré tout un nouveau lien, la relecture retourne toujours le lien payé pour cette référence.
Récupérer une notification manquée
Une notification n'est envoyée qu'une fois. Si votre endpoint ne l'a pas reçue, lisez l'issue avec l'API de statut de lien de paiement, en utilisant votre reference. Vous pouvez aussi renvoyer la notification depuis le tableau de bord : voir Notifications en échec.