Skip to main content

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

ItemValue
Base URLhttps://app.papi.mg/engine/api
Path/payment-links
MethodPOST
Request bodyJSON
Response bodyJSON
Legacy URL

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.

warning

A missing or unknown API key is answered with 400 (not 401) on this endpoint. See Errors.


Request headers

HeaderRequiredValueDescription
Token<YOUR_API_KEY>API key of your shop.
Content-Typeapplication/jsonThe 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

FieldTypeRequiredConstraints / validationDescription
amountnumberNot null. Minimum 300.Amount to pay, in currency.
referencestringNot 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.
clientNamestringNot blank.Customer's name.
descriptionstringNot blank. Maximum 255 characters.Short description of the payment.
successUrlstringMust start with http:// or https://. Must be sent together with failureUrl.URL the customer is redirected to after a successful payment.
failureUrlstringMust start with http:// or https://. Must be sent together with successUrl.URL the customer is redirected to after a failed payment.
notificationUrlstringMust 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.
validDurationintegerInteger greater than 0, at most 596. Default: 1.Validity of the link, in hours, counted from its creation (596 hours is about 24 days).
providerstringOne of MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED.Restricts the link to one provider. When omitted, the customer chooses on the payment page.
currencystringA 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.
displayCurrencystringOnly MGA is accepted. Default: MGA.Currency shown to the customer on the payment page.
payerEmailstringValid email address. An empty string is treated as absent.Customer's email address.
payerPhonestringValid 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).
isTestModebooleanDefault: false.Set to true to flag the link as a test. See Test mode.
testReasonstringNone.Reason for the test. Shown in the dashboard.
paymentTesterstringOne of PAPI_DEV, PAPI_TEST, MERCHANT_DEV, MERCHANT, EXTERNAL_DEV (case-insensitive).Who runs the test. Stored with the link.
linkStatebooleanNone.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.

FieldTypeDescription
amountnumberAmount of the link.
currencystringCurrency of amount (MGA).
displayCurrencystringCurrency shown to the customer (MGA).
linkCreationDateTimeintegerCreation time, epoch milliseconds.
linkExpirationDateTimeintegerExpiry time, epoch milliseconds (linkCreationDateTime + validDuration hours).
paymentLinkstringThe 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.
clientNamestringCustomer's name.
paymentReferencestringIn this response, it is your reference. It is not Papi's payment reference.
descriptionstringDescription of the payment.
successUrlstringRedirection URL after success, or null.
failureUrlstringRedirection URL after failure, or null.
notificationUrlstringNotification URL, or null.
payerEmailstringCustomer's email, or null.
payerPhonestringCustomer's phone number in international format, or null.
notificationTokenstringToken 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.
testReasonstringReason for the test, or null.
isTestModebooleanWhether the link is flagged as a test.
shortLinkstringShort form of paymentLink. null when the short link could not be generated; the link itself is still valid.
Which reference is which
  • 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 shop200 with a new link.
…has a live link with the same amount, currency and displayCurrency200 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 displayCurrency409 with the code PAYMENT_LINK_CONFLICT. No link is created.
…was used for a link that is now paid, expired, or disabled200 with a new link. The old link is not changed.

Details

  • Only the money is compared. amount, currency and displayCurrency must be identical. amount is compared as an exact value: 15000 and 15000.0 are identical, 15000 and 15000.5 are different. Omitted currency and displayCurrency count as MGA.
  • 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, isTestMode and testReason from the retry are not applied. In particular, a retry does not extend the validity of the link.
  • The reference is matched exactly. ORDER-123 and order-123 are two different references.
  • The scope is your shop. The same reference used by another shop has no effect on your requests.
  • Concurrent requests are serialised. If several requests with the same reference arrive at the same time, the first one creates the link and the others receive that same link (or a 409 if 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 example ORDER-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 statusError codeMeaningExample message
400CORE_INPUT_400A 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.
400CORE_INPUT_400The Token header is missing, or the API key is unknown.API key invalide
400CORE_INPUT_400displayCurrency is not MGA.EUR is not supported yet
400CORE_INPUT_400currency is not a currency code known to Papi (the check is case-sensitive: mga is refused).Unité monétaire invalide
400CORE_INPUT_400paymentTester is not one of the accepted values.Invalid value 'SOMEONE
400CORE_INPUT_400payerEmail or payerPhone is not valid.Invalid email address: not-an-email / Numéro de téléphone invalide
400CORE_INPUT_400The 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'
409PAYMENT_LINK_CONFLICTA 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.
503ENGINE_UNAVAILABLELegacy dashboard URL only: the engine could not be reached.Le service de paiement est momentanément indisponible. Veuillez réessayer.

Validation messages

FieldRulemessage
amountMissingLe montant est requis
amountLower than 300Le montant doit être supérieur ou égal à 300.
clientNameMissing or blankLe nom du client est réquis
referenceMissing or blankLa référence est réquise
descriptionMissing or blankLa description est réquise
descriptionLonger than 255 charactersLa description ne doit pas dépasser 255 caractères
successUrlDoes not start with http:// or https://L'URL de succès doit être valide et commencer par http ou https
failureUrlDoes not start with http:// or https://L'URL d'échec doit être valide et commencer par http ou https
successUrl / failureUrlOnly one of the two is sentLes URLs de succès et d'échec doivent être toutes les deux définies ou toutes les deux absentes
notificationUrlDoes not start with http:// or https://L'URL de notification doit être valide et commencer par http ou https
validDuration0 or negativeLa durée de validité doit être supérieure à 0
providerNot one of the accepted valuesValeur invalide pour provider
payerEmailNot a valid email addressInvalid email address: <value>
payerPhoneNot a valid phone numberNumé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."
}
}
tip

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: isTestMode is always true in 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 5xx answers. Do not retry a 400 without changing the request: the same body is refused again.
  • Do not retry a 409 with the same body. Follow the options in Conflict response.
  • Use the notificationToken of the last successful response. For a retry that returned the existing link, it is the same value as the first response.
  • 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 new reference, 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 new notificationToken.
  • 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.