Skip to main content

3. Integrate Papi to your web application

Integrate Papi into your application, including handling payment flows, notifications, and environment‑specific settings.


Environments

Papi provides a single production endpoint. For testing, use the test mode flags described below.

  • Create a payment link (live transactions): https://app.papi.mg/engine/api/payment-links
  • Read a payment link back: https://app.papi.mg/engine/api/payment-links/{merchantPaymentReference} (see Reading a payment link back)

https://app.papi.mg/dashboard/api/payment-links is legacy, still accepted for existing integrations; use the engine URL for new ones.


Payment Flow Overview

The payment process consists of the following steps:

  1. Get your API Key from the dashboard.
  2. Create a payment link: Send an API request to generate a unique payment link for the customer.
  3. Redirect the customer: Use the returned paymentLink to send the customer to the secure payment page.
  4. Process the payment: The customer completes the payment on the secure page.
  5. Receive a notification: After the payment, Papi calls your notificationUrl with the final status.
  6. Verify and handle the result: Verify the X-Papi-Signature header first, then check the notificationToken and merchantPaymentReference, and update your application. See Securing notifications (callbacks).

Step‑by‑Step Guide to Implementing Payment Integration

This guide walks you through integrating the payment API step by step, from setting up redirection pages to handling notifications.

1
Create pages for redirection

You need two pages on your website where customers will be sent after payment:

  • Success URL – The page shown after a successful payment (e.g., order confirmation).
  • Failure URL – The page shown after a failed payment (e.g., an error message with a retry option).

Take note of both URLs – you will need them when creating the payment link.

2
Create the notification endpoint (Callback URL)

The notification endpoint is a callback URL you implement to receive automatic status updates from Papi. It must accept POST requests.

If you are developing on your local machine

Why http://localhost:… does not work. Papi calls your notificationUrl from its own servers, over the public Internet. localhost (like 127.0.0.1 or a private IP such as 192.168.x.x) refers to the machine making the call: from Papi's servers, that address does not point to your computer. The notification will never reach you.

Expose your local port temporarily. Use a tunnel that publishes a public HTTPS URL forwarding to your local server, for example:

# ngrok — expose local port 3000
ngrok http 3000

# Cloudflare Tunnel — equivalent
cloudflared tunnel --url http://localhost:3000

The tool prints a public URL, for example https://abcd-1234.ngrok-free.app.

Build the public notificationUrl. Concatenate the tunnel URL and your endpoint path:

https://abcd-1234.ngrok-free.app/api/papi/notification

Send this value (not http://localhost:3000/api/papi/notification) in the notificationUrl field when creating the payment link.

The tunnel URL is temporary. On most free plans it changes every time you restart the tunnel (and expires after a period of inactivity). After a restart, take the new URL and create a new payment link with the updated notificationUrl — links already created still point at the old, now dead address.

Observe the incoming request and test both cases. Log the received POST body at the very start of your handler, before any processing. Tunnels also provide a request inspector (ngrok: http://127.0.0.1:4040) showing headers, body and response code, and letting you replay a request without redoing a payment. Then test:

  • Success: complete a payment and confirm you receive a paymentStatus of SUCCESS.
  • Failure: cancel the payment or let the link expire (short validDuration) to receive FAILED, and confirm your code does not mark the order as paid.

Minimum precautions

  • Use the tunnel's HTTPS URL, never the http:// one.
  • Always verify the X-Papi-Signature header, then notificationToken and merchantPaymentReference, before updating your data — the tunnel URL is public and anyone can post to it. See Securing notifications (callbacks).
  • Never put your API key in the notification URL (or in a query string): it would show up in tunnel and intermediate server logs.
  • Stop the tunnel as soon as the test is over: while it runs, your local server is reachable from the Internet.

Example notification body sent by Papi

{
"paymentStatus": "SUCCESS",
"paymentMethod": "MVOLA",
"currency": "MGA",
"amount": 15000,
"fee": 500,
"clientName": "Client Name",
"description": "Payment for Order #123",
"merchantPaymentReference": "ORDER-123",
"paymentReference": "c1f4a5b0-6f5e-4e1b-9f0e-2b7d8a9c3d21",
"notificationToken": "xyz789",
"message": "Payment completed successfully.",
"payerEmail": "customer@example.com",
"payerPhone": "+261340000000"
}

Explanation of notification fields

FieldTypeDescription
paymentStatusstringSUCCESS, PENDING, or FAILED.
paymentMethodstringThe method used (MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED).
currencystringCurrency code (always MGA).
displayCurrencystringThe currency the payer saw on the form (always MGA today).
amountintegerAmount paid.
estimatedAmountintegerThe amount expressed in displayCurrency (equal to amount while only MGA is supported).
feeintegerTransaction fee deducted.
clientNamestringCustomer's name as provided.
descriptionstringThe payment description you sent.
merchantPaymentReferencestringYour reference for this payment — the reference you sent when creating the link.
paymentReferencestringPapi's reference for this payment (a UUID). It is returned as papiPaymentReference when you read the link back.
notificationTokenstringToken returned when you created the payment link – use it as an additional authenticity check, after the signature.
messagestringAdditional human‑readable information.
payerEmailstringCustomer's email (if provided).
payerPhonestringCustomer's phone number (if provided).

Verifying the notification

Papi signs every notification it sends to your notificationUrl. Before you update your data:

  1. Verify the signature in the X-Papi-Signature header, with the signing secret of your application.
  2. Check that merchantPaymentReference and notificationToken match the values of your payment link.
Full procedure

See Securing notifications (callbacks) for the headers, the signing secret, how the signature is built, code examples (Node.js, PHP, Python, Java), a test vector, and best practices to secure your endpoint.

If a notification never arrives

A notification is sent once. If your endpoint was down or the call was lost, do not leave the order in limbo: read the payment link back with GET /engine/api/payment-links/{merchantPaymentReference} (see Reading a payment link back) and use the paymentStatus it returns. You can also resend the notification from the dashboard: see Failed notifications.

3
Prepare the request body and headers for creating a payment link

You will send a POST request to generate a payment link.
The body must include the URLs you created in Steps 1 and 2, along with payment details.

Endpoint

POST https://app.papi.mg/engine/api/payment-links

Headers

{
"Content-Type": "application/json",
"Token": "<YOUR_API_KEY>"
}

Request body example

{
"amount": 15000.0,
"clientName": "Client Name",
"reference": "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",
"validDuration": 60,
"provider": "MVOLA",
"payerEmail": "customer@example.com",
"payerPhone": "+261340000000",
"testReason": "Internal QA",
"isTestMode": false
}

Request fields explained

FieldTypeRequiredDescription
clientNamestringCustomer's name.
amountnumberPayment amount (minimum 300).
referencestringYour unique identifier for this payment (e.g., order ID).
descriptionstringShort description (max 255 chars).
successUrlstringURL for redirection after success (must be http(s)://).
failureUrlstringURL for redirection after failure (must be http(s)://).
notificationUrlstringYour endpoint that receives payment notifications (http(s)://). Strongly recommended; without it, read the outcome back with the GET below.
validDurationintegerLink validity in hours (default: 1, must be > 0).
providerstringRestrict to one provider: MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED.
payerEmailstringCustomer's email address.
payerPhonestringCustomer's phone number.
testReasonstringReason for using test mode (appears in dashboard).
isTestModebooleanSet to true to enable test mode (see "Test Mode" section below).
Full reference

All fields, validation rules, error codes and examples: Payment link creation API.

4
Send the POST request to retrieve the payment link

Make the request with the body and headers from Step 3.
If successful, you receive a response like this:

{
"data": {
"amount": 15000.0,
"currency": "MGA",
"linkCreationDateTime": 1788065989011,
"linkExpirationDateTime": 1788281989011,
"paymentLink": "https://payment-form.papi.mg/yourshop/payments/eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJDbGllbnQgTmFtZSIsImV4cCI6MTc4ODI4MTk4OX0.Yx8kQ0rN2mVvL5tJ7pF3cHqA9sWbZ1dE4gT6uK0nX2Y",
"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": "+261340000000",
"notificationToken": "xyz789",
"testReason": "Internal QA",
"isTestMode": false
}
}

Important response fields

FieldDescription
paymentLinkThe URL where the customer must be redirected to pay. Its shape is https://payment-form.papi.mg/<your-application-code>/payments/<jwt>, where the JWT is issued by Papi and expires with the link.
notificationTokenKeep this – you can compare it with the notificationToken of future notifications, as an additional check after the signature.
paymentReferenceIn this response it echoes your reference — the same value that comes back as merchantPaymentReference in notifications and in the read-back. It is not Papi's payment reference, which is called paymentReference in the notification and papiPaymentReference in the read-back.

Sending the same request twice is safe: see Retries and idempotency.

5
Redirect the user to the payment URL

Choose how you want to send the customer to the payment page. Two flows are supported – pick the one that fits your UX.

Option A — Standard redirect

Extract the paymentLink from the response and redirect the customer:

  • Web applications: Open the link in a new browser tab, or perform an HTTP redirect.
  • Mobile apps: Use a WebView or the device's default browser.
    Tip: Some platforms reset WebViews when the app goes to the background – handle this carefully to avoid losing state.

Once redirected, the customer completes the payment on Papi's secure page. After the transaction, they are sent back to your successUrl or failureUrl, and your notificationUrl receives the final status.

Payment choice screen


Option B — In-app pop-up window

Keep the customer on your site by opening the paymentLink in a popup window and listening for a postMessage event from Papi when the payment is confirmed.

1. Open the payment popup

Open the URL returned by the server in a new popup window. This must happen inside a user-gesture handler (e.g. a form submit event) — otherwise the browser will block the popup. We will use JavaScript to demonstrate, but the same logic applies in any frontend framework.

var paymentWindow = window.open(
paymentLink, // URL from the previous step
'payment-form-window', // reusable window name
'width=500,height=700' // popup dimensions
);

Keep a reference to paymentWindow — you will need it to verify that the message comes from exactly this popup.

2. Listen for payment success

Once the popup is open, register a message listener on the parent window. Papi will post { type: 'PAYMENT_STATUS' } when the payment is confirmed.

let PAPI_ORIGIN  = 'https://payment-form.papi.mg';

let paymentMessageHandler = null;
let paymentSuccess = false;

function teardownPaymentMessageListener() {
if (paymentMessageHandler) {
window.removeEventListener('message', paymentMessageHandler);
paymentMessageHandler = null;
}
}

function onPaymentSuccessMessage () {
// Your success handling logic here
}

function setupPaymentMessageListener() {
teardownPaymentMessageListener();

paymentMessageHandler = function (event) {
// 1. Reject anything not from the Papi origin
if (event.origin !== PAPI_ORIGIN) return;
// 2. Reject anything not from the popup we opened
if (event.source !== paymentWindow) return;
// 3. Only react to PAYMENT_STATUS payloads
if (!event.data || event.data.type !== 'PAYMENT_STATUS') return;
// 4. Don't double-process
if (paymentSuccess) return;

paymentSuccess = true;
teardownPaymentMessageListener();
onPaymentSuccessMessage();
};

window.addEventListener('message', paymentMessageHandler);
}

Call setupPaymentMessageListener() immediately after window.open() so no message is missed between the two calls:

paymentWindow = window.open(paymentLink, 'payment-form-window', 'width=500,height=700');
setupPaymentMessageListener();

3. Security guards

Three checks must all pass before acting on a message:

CheckWhy it matters
event.origin === PAPI_ORIGINRejects messages from any other domain. Must be an exact match — scheme + host, no trailing slash.
event.source === paymentWindowRejects messages from unrelated iframes or tabs that happen to share the origin.
event.data.type === 'PAYMENT_STATUS'Ignores other postMessage traffic on the same page (analytics, widgets, etc.).

4. Teardown

Always remove the listener once you're done — on success, on cancel, or when the user closes the popup:

// On success → handled automatically inside the listener above

// On cancel (user clicks "Annuler")
teardownPaymentMessageListener();
if (paymentWindow && !paymentWindow.closed) { paymentWindow.close(); }
paymentWindow = null;

Common pitfalls

SymptomCause
Listener never firesPAPI_ORIGIN mismatch (http vs https, trailing slash, port).
Listener fires for unrelated messagesMissing event.source === paymentWindow guard.
Redirect happens twiceMissing paymentSuccess flag or listener not torn down after first fire.
event.source is nullPopup closed before sending the message — ensure the message is sent prior to window.close().

Retries and idempotency

Creating a payment link is idempotent on the reference, within your shop, for as long as the link is live (not expired, not paid, not disabled):

You POST again with the same referencePapi answers
…same amount and currency, while the first link is live200 with the existing link: same paymentLink, same notificationToken. Description, URLs and payer details on the retry are ignored.
…a different amount or currency, while the first link is live409 with code PAYMENT_LINK_CONFLICT. Wait for the link to expire, or use another reference.
…after the first link was paid, expired, or disabled200 with a new link.

So a network retry or a double-submitted checkout can never leave two payable links for one order. Simultaneous requests are serialised: they all receive the same link.

{
"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."
}
}

Links created by hand from the dashboard are outside this rule: they are neither returned nor a reason to refuse.

Full reference

Detailed idempotency rules, retry advice and error codes: Payment link creation API.


Anything you created with POST /payment-links can be read back by your merchant reference. This is how you recover the outcome of a payment when the notification never reached you, and how you check where a link stands before re-issuing one.

Full reference

All fields, status rules, error codes and examples: Get payment link status API.

Endpoint

GET https://app.papi.mg/engine/api/payment-links/{merchantPaymentReference}

{merchantPaymentReference} is your merchant reference: the reference field you sent when creating the link, the same value returned as merchantPaymentReference in responses and notifications. It is not Papi's payment reference. It is resolved inside the shop your Token belongs to. If several links were created under the same reference, a paid one is returned if any exists, whatever its age; otherwise the most recent one.

Headers

{
"Token": "<YOUR_API_KEY>"
}

Response example

{
"data": {
"linkStatus": "PAID",
"paymentStatus": "SUCCESS",
"paymentMethod": "MVOLA",
"currency": "MGA",
"displayCurrency": "MGA",
"amount": 15000.0,
"clientName": "Client Name",
"description": "Payment for Order #123",
"merchantPaymentReference": "ORDER-123",
"papiPaymentReference": "c1f4a5b0-6f5e-4e1b-9f0e-2b7d8a9c3d21",
"notificationToken": "xyz789",
"message": null,
"payerEmail": "customer@example.com",
"payerPhone": "+261340000000",
"paymentLink": "https://payment-form.papi.mg/yourshop/payments/eyJhbGciOiJIUzI1NiJ9...",
"shortLink": "https://link.papi.mg/8NW7R",
"linkCreationDateTime": 1788065989011,
"linkExpirationDateTime": 1788281989011,
"isTestMode": false
}
}

Response fields

The fields share their names and meaning with the notification body, with one exception: Papi's reference is called papiPaymentReference here (it is paymentReference in the notification) so it can never be confused with your merchantPaymentReference.

FieldTypeDescription
linkStatusstringWhere the link stands: ACTIVE (can still be paid), EXPIRED, PAID, or DISABLED. PAID wins over DISABLED and EXPIRED.
paymentStatusstringOutcome of the payment made through the link: SUCCESS, PENDING, or FAILED — the same values as the notification. null while nobody has attempted to pay.
paymentMethodstringProvider the payer went through (MVOLA, AIRTEL_MONEY, ORANGE_MONEY, BRED). null until a payment attempt exists.
currencystringCurrency code (always MGA).
displayCurrencystringCurrency shown to the payer.
amountnumberAmount of the link.
clientNamestringCustomer's name as provided.
descriptionstringThe description you sent.
merchantPaymentReferencestringYour reference from the creation request.
papiPaymentReferencestringPapi's reference for the payment attempt — the notification's paymentReference. null until a payment attempt exists.
notificationTokenstringThe token returned when the link was created.
messagestringFailure reason of the payment attempt, when there is one.
payerEmailstringCustomer's email (if provided).
payerPhonestringCustomer's phone number (if provided).
paymentLinkstringThe payment URL, as returned at creation.
shortLinkstringShort form of the payment URL, when one was generated.
linkCreationDateTimeintegerCreation time, epoch milliseconds.
linkExpirationDateTimeintegerExpiry time, epoch milliseconds.
isTestModebooleanWhether the link was flagged as a test.

How to read the two statuses together

linkStatuspaymentStatusMeaning
ACTIVEnullNobody has tried to pay yet.
ACTIVEFAILEDThe last attempt was declined or abandoned; the payer can still retry on the same link.
ACTIVEPENDINGAn attempt is in progress at the provider. Check again shortly.
PAIDSUCCESSThe payment went through. Confirm the order.
EXPIREDnull / FAILEDThe link ran out before a successful payment. Create a new one.
DISABLEDnull / FAILEDThe link was switched off from the dashboard.

Errors

StatusMeaning
401Token header missing or unknown.
404No link with this reference in your shop. A reference used by another shop is also a 404.

Test Mode

Papi offers two ways to test your integration:

1. isTestMode flag in the request

  • Set "isTestMode": true in the request body.
  • The transaction is marked as a test in your dashboard, but real money is still moved.
  • Useful for end‑to‑end testing with real providers (except for mobile money, which does not support non‑real test transactions).

2. Application Test Mode (cards only)

  • In your shop's settings inside the dashboard, enable Test Mode.
  • Use the following test card details:
    • Card number: 4000 0000 0000 5126
    • Expiry date: 01/2028
    • CVV: 123
  • This simulates a card payment without moving real funds.

Example Workflow Summary

  1. Obtain your API Key from the dashboard (Avatar icon → Boutiques → select shop → Developer tab).
  2. Create a payment link by sending a POST request with the required fields. Retries are safe: the same reference with the same amount returns the same link.
  3. Redirect the customer to the returned paymentLink.
  4. Receive a notification on your notificationUrl when the payment status changes.
  5. Verify the notification: check the X-Papi-Signature header first, then merchantPaymentReference and notificationToken. See Securing notifications (callbacks).
  6. Update your records and inform the customer.
  7. Read the link back with GET /engine/api/payment-links/{merchantPaymentReference} whenever a notification is missing or you need to double-check an outcome.

By following these steps, you can securely accept online payments with Papi. Always test thoroughly using test mode before going live.