Payment link creation API
Creates a payment link for your shop. You redirect the customer to the returned paymentLink, and Papi notifies your notificationUrl when the payment status changes.
For a step-by-step walkthrough of the whole payment flow, see the integration guide.
Endpoint
POST https://app.papi.mg/engine/api/payment-links
| Item | Value |
|---|---|
| Base URL | https://app.papi.mg/engine/api |
| Path | /payment-links |
| Method | POST |
| Request body | JSON |
| Response body | JSON |
POST https://app.papi.mg/dashboard/api/payment-links is still accepted for existing integrations. It forwards the request to the engine unchanged and returns the engine's answer unchanged, so the behaviour described on this page is the same on both URLs. The only difference: if the engine cannot be reached, the legacy URL answers 503 with the code ENGINE_UNAVAILABLE. Use the engine URL for new integrations.
Authentication
Every request must carry your shop's API key in the Token header. You find the API key in the dashboard: Avatar icon → Boutiques → select shop → Developer tab.
The link is created in the shop that the API key belongs to. Idempotency (see Idempotency) is also evaluated inside that shop.
A missing or unknown API key is answered with 400 (not 401) on this endpoint. See Errors.
Request headers
| Header | Required | Value | Description |
|---|---|---|---|
Token | ✓ | <YOUR_API_KEY> | API key of your shop. |
Content-Type | ✓ | application/json | The body is JSON. The legacy dashboard URL refuses any other content type. |
No other header is read by this endpoint. In particular, the LinkToken header used by the payment page is not accepted as an authentication method here. Test mode is set in the body with isTestMode, not with a header.
Body parameters
| Field | Type | Required | Constraints / validation | Description |
|---|---|---|---|---|
amount | number | ✓ | Not null. Minimum 300. | Amount to pay, in currency. |
reference | string | ✓ | Not blank. | Your identifier for this payment (for example, your order ID). It is the idempotency key: see Idempotency. It is returned as paymentReference in the creation response and as merchantPaymentReference in notifications and in the read-back. |
clientName | string | ✓ | Not blank. | Customer's name. |
description | string | ✓ | Not blank. Maximum 255 characters. | Short description of the payment. |
successUrl | string | ✗ | Must start with http:// or https://. Must be sent together with failureUrl. | URL the customer is redirected to after a successful payment. |
failureUrl | string | ✗ | Must start with http:// or https://. Must be sent together with successUrl. | URL the customer is redirected to after a failed payment. |
notificationUrl | string | ✗ | Must start with http:// or https://. | Your endpoint that receives payment notifications. Strongly recommended. Without it, read the outcome with the Get payment link status API. |
validDuration | integer | ✗ | Integer greater than 0, at most 596. Default: 1. | Validity of the link, in hours, counted from its creation (596 hours is about 24 days). |
provider | string | ✗ | One of MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED. | Restricts the link to one provider. When omitted, the customer chooses on the payment page. |
currency | string | ✗ | A currency code known to Papi (ISO 4217 style, uppercase). Default: MGA. | Currency of amount. Only MGA is settled today: send MGA or omit the field. Another known code is accepted by this endpoint, but Papi does not settle payments in it. |
displayCurrency | string | ✗ | Only MGA is accepted. Default: MGA. | Currency shown to the customer on the payment page. |
payerEmail | string | ✗ | Valid email address. An empty string is treated as absent. | Customer's email address. |
payerPhone | string | ✗ | Valid phone number. Allowed characters: digits, +, -, space, (, ), .. Numbers without a country code are read as Malagasy numbers. An empty string is treated as absent. | Customer's phone number. It is returned in international format (for example +261 34 00 000 00). |
isTestMode | boolean | ✗ | Default: false. | Set to true to flag the link as a test. See Test mode. |
testReason | string | ✗ | None. | Reason for the test. Shown in the dashboard. |
paymentTester | string | ✗ | One of PAPI_DEV, PAPI_TEST, MERCHANT_DEV, MERCHANT, EXTERNAL_DEV (case-insensitive). | Who runs the test. Stored with the link. |
linkState | boolean | ✗ | None. | Accepted for compatibility and ignored: a link created through the API is always enabled. |
Any other field in the body is ignored.
Request example
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
}'
Request body only:
{
"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
}
Success response
Status: 200 OK. The same status is returned when an existing live link is returned (see Idempotency).
{
"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"
}
}
Response fields
Every field is present in the response. A field without a value is null.
| Field | Type | Description |
|---|---|---|
amount | number | Amount of the link. |
currency | string | Currency of amount (MGA). |
displayCurrency | string | Currency shown to the customer (MGA). |
linkCreationDateTime | integer | Creation time, epoch milliseconds. |
linkExpirationDateTime | integer | Expiry time, epoch milliseconds (linkCreationDateTime + validDuration hours). |
paymentLink | string | The URL where the customer must be redirected to pay. Its shape is https://payment-form.papi.mg/<your-application-code>/payments/<jwt>. The JWT is issued by Papi and expires with the link. When provider is set, the URL ends with /mobile/<PROVIDER> (for example /mobile/MVOLA) or /card/BRED. |
clientName | string | Customer's name. |
paymentReference | string | In this response, it is your reference. It is not Papi's payment reference. |
description | string | Description of the payment. |
successUrl | string | Redirection URL after success, or null. |
failureUrl | string | Redirection URL after failure, or null. |
notificationUrl | string | Notification URL, or null. |
payerEmail | string | Customer's email, or null. |
payerPhone | string | Customer's phone number in international format, or null. |
notificationToken | string | Token generated by Papi for this link (a UUID). Store it: every notification for this link carries the same value, as an additional check after the signature. |
testReason | string | Reason for the test, or null. |
isTestMode | boolean | Whether the link is flagged as a test. |
shortLink | string | Short form of paymentLink. null when the short link could not be generated; the link itself is still valid. |
reference(request) =paymentReference(this response) =merchantPaymentReference(notifications and read-back): your reference.paymentReference(notifications) =papiPaymentReference(read-back): Papi's reference for a payment attempt. It does not exist yet when the link is created.
Idempotency
Creating a payment link is idempotent on reference, inside your shop, for as long as the link is live.
A link is live when all of the following conditions are true:
- it was created through this API (not from the dashboard);
- it is enabled;
- it is not paid;
- its expiry time is in the future.
Behaviour
You send a POST with a reference that… | Papi answers |
|---|---|
| …has no live link in your shop | 200 with a new link. |
…has a live link with the same amount, currency and displayCurrency | 200 with the existing link: same paymentLink, same notificationToken, same linkExpirationDateTime. No new link is created. |
…has a live link with a different amount, currency or displayCurrency | 409 with the code PAYMENT_LINK_CONFLICT. No link is created. |
| …was used for a link that is now paid, expired, or disabled | 200 with a new link. The old link is not changed. |
Details
- Only the money is compared.
amount,currencyanddisplayCurrencymust be identical.amountis compared as an exact value:15000and15000.0are identical,15000and15000.5are different. OmittedcurrencyanddisplayCurrencycount asMGA. - Other fields of the retry are ignored. When the existing link is returned, the response describes the link as it was first created.
clientName,description,successUrl,failureUrl,notificationUrl,validDuration,provider,payerEmail,payerPhone,isTestModeandtestReasonfrom the retry are not applied. In particular, a retry does not extend the validity of the link. - The reference is matched exactly.
ORDER-123andorder-123are two different references. - The scope is your shop. The same
referenceused by another shop has no effect on your requests. - Concurrent requests are serialised. If several requests with the same
referencearrive at the same time, the first one creates the link and the others receive that same link (or a409if their amount or currency is different). A double-submitted checkout cannot create two payable links for one order. - Dashboard links are outside the rule. A link created by hand from the dashboard with the same reference is never returned by this endpoint and is never a reason for a
409. - Validation comes first. A request that fails validation (
400) is refused before the idempotency check. It never returns an existing link.
Conflict response
Status: 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."
}
}
To resolve a conflict, choose one of these options:
- wait for the live link to expire, then send the request again;
- send the request with another
reference(for exampleORDER-123-2); - if you do not know the state of the existing link, read it with the Get payment link status API.
Errors
All errors in the table below use this body format:
{
"error": {
"code": "<ERROR_CODE>",
"message": "<Human-readable message, in French>"
}
}
| HTTP status | Error code | Meaning | Example message |
|---|---|---|---|
400 | CORE_INPUT_400 | A body field failed validation. When several fields fail, their messages are joined with , in no fixed order. | Le montant doit être supérieur ou égal à 300. |
400 | CORE_INPUT_400 | The Token header is missing, or the API key is unknown. | API key invalide |
400 | CORE_INPUT_400 | displayCurrency is not MGA. | EUR is not supported yet |
400 | CORE_INPUT_400 | currency is not a currency code known to Papi (the check is case-sensitive: mga is refused). | Unité monétaire invalide |
400 | CORE_INPUT_400 | paymentTester is not one of the accepted values. | Invalid value 'SOMEONE |
400 | CORE_INPUT_400 | payerEmail or payerPhone is not valid. | Invalid email address: not-an-email / Numéro de téléphone invalide |
400 | CORE_INPUT_400 | The body is not valid JSON, or a field has the wrong type (for example "amount": "abc"). | Corps de la requête invalide : JSON attendu / Valeur invalide pour le champ 'amount' |
409 | PAYMENT_LINK_CONFLICT | A live link already exists for this reference with a different amount or currency. See Idempotency. | 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 | Legacy dashboard URL only: the engine could not be reached. | Le service de paiement est momentanément indisponible. Veuillez réessayer. |
Validation messages
| Field | Rule | message |
|---|---|---|
amount | Missing | Le montant est requis |
amount | Lower than 300 | Le montant doit être supérieur ou égal à 300. |
clientName | Missing or blank | Le nom du client est réquis |
reference | Missing or blank | La référence est réquise |
description | Missing or blank | La description est réquise |
description | Longer than 255 characters | La description ne doit pas dépasser 255 caractères |
successUrl | Does not start with http:// or https:// | L'URL de succès doit être valide et commencer par http ou https |
failureUrl | Does not start with http:// or https:// | L'URL d'échec doit être valide et commencer par http ou https |
successUrl / failureUrl | Only one of the two is sent | Les URLs de succès et d'échec doivent être toutes les deux définies ou toutes les deux absentes |
notificationUrl | Does not start with http:// or https:// | L'URL de notification doit être valide et commencer par http ou https |
validDuration | 0 or negative | La durée de validité doit être supérieure à 0 |
provider | Not one of the accepted values | Valeur invalide pour provider |
payerEmail | Not a valid email address | Invalid email address: <value> |
payerPhone | Not a valid phone number | Numéro de téléphone invalide |
Example of a validation error:
{
"error": {
"code": "CORE_INPUT_400",
"message": "La description est réquise, Le montant doit être supérieur ou égal à 300."
}
}
An empty string is not the same as an absent field for URL fields. "successUrl": "" fails validation. If you do not use a URL, omit the field.
Test mode
- If your shop is a sandbox application, every link it creates is a test link:
isTestModeis alwaystruein the response, whatever you send. - If your shop is in production, the link is a test link only when you send
"isTestMode": true. The link is flagged as a test in your dashboard, but real money is still moved.
For card test payments without real money, see Test Mode in the integration guide.
Practical notes
Retrying a request
- If the request times out or the connection drops, send the same request again with the same
reference. You receive the link that was created by the first request, or a new link if the first request never reached Papi. In both cases, only one live link exists. - Retry on network errors and on
5xxanswers. Do not retry a400without changing the request: the same body is refused again. - Do not retry a
409with the same body. Follow the options in Conflict response. - Use the
notificationTokenof the last successful response. For a retry that returned the existing link, it is the same value as the first response.
Issuing a link again for the same order
- The link is still live and the amount is unchanged: send the request again. You receive the same link. You can send it to the customer again.
- The order amount changed while the link is live: you receive a
409. Use a newreference, or wait for the link to expire. - The link expired or was disabled: send the request again with the same
reference. You receive a new link with a newnotificationToken. - The link was paid: do not issue a new link. Before you issue a link again, check the state with the Get payment link status API. If you create a new link anyway, the read-back still returns the paid link for this reference.
Recovering a missed notification
A notification is sent once. If your endpoint did not receive it, read the outcome with the Get payment link status API, using your reference. You can also resend the notification from the dashboard: see Failed notifications.