ElyonPay API Documentation
Welcome to the ElyonPay API technical documentation. This reference allows you to integrate Mobile Money payments, Visa, Mastercard and transfers into your application in just a few lines of code.
https://api.elyonpay.net/api (Sandbox) / https://api.elyonpay.org/api (Production)All requests must be sent over HTTPS. Unsecured HTTP requests are rejected. See the Environments section for details.
Key Features
- Unified Mobile Money — MTN MoMo, Orange Money, Wave, Moov, Airtel Money
- Bank Cards — Visa, Mastercard with 3D Secure, PCI DSS level 1 compliant
- Simple Integration — Payment link + iframe, active status verification
- Full Sandbox — Test for free with simulated data
- Multi-currency — XAF, XOF, EUR, USD, GBP, NGN, CDF, KES
- Official SDKs — JavaScript, PHP, Python, Java, React Native, Flutter
Quick Start
Follow these 3 steps to make your first payment:
- Contact us to get your API key
- Install the SDK for your language or use cURL directly
- Call
POST /api/request-to-pay/payment/linkwith the amount and redirect URLs
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 15000,
"user_lang": "fr",
"msisdn": "+237600000000",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error"
}'Authentication
The ElyonPay API uses JWT (JSON Web Token) authentication. Obtain your token via the login endpoint, then include it in every request.
| Parameter | Type | Required | Description |
|---|---|---|---|
| username | string | Required | Merchant account email |
| password | string | Required | Account password |
| role | string | Required | ROLE_MERCHANT_ADMIN |
curl -X POST https://api.elyonpay.org/api/login \
-H "Content-Type: application/json" \
-d '{
"username": "you@example.com",
"password": "***",
"role": "ROLE_MERCHANT_ADMIN"
}'Response
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...."
}Usage in HTTP Header
Authorization: Bearer <your_jwt_token>API Key (scoped access)
As an alternative to JWT, a server-to-server API key authenticates your payment calls. It is available for every merchant account and can be retrieved — or regenerated — from your dashboard.
Authorization: Bearer pk_live_...Environments
ElyonPay offers two distinct environments to allow you to develop and test safely before going to production.
Base URLs
| Environment | Base URL | Description |
|---|---|---|
Sandbox | https://api.elyonpay.net/api | No real charges — simulated data |
Production | https://api.elyonpay.org/api | Real transactions |
Mobile Money Test Numbers
| Number | Network | Simulated Behavior |
|---|---|---|
+237600000001 | MTN MoMo | Successful payment |
+237600000002 | MTN MoMo | Insufficient balance |
+237690000001 | Orange Money | Successful payment |
+237690000002 | Orange Money | Simulated timeout |
+221700000001 | Wave | Successful payment |
Sandbox & Tests
The sandbox environment lets you test the complete integration without making real payments. Connect to api.elyonpay.net — no real charges will be made.
Test Accounts by Country
Use these credentials to test the integration on the sandbox environment. Each account is associated with a specific country and currency.
| Phone | Password | |
|---|---|---|
+237683191284 | demo+23733@elyonpay.com | CompteDemo1 |
| Phone | Password | |
|---|---|---|
+2250758789285 | demo+225@elyonpay.com | CompteDemo1 |
| Phone | Password | |
|---|---|---|
+33895555555 | demo+33@elyonpay.com | CompteDemo1 |
Sandbox Limits
- Sandbox transactions incur no fees
- Data is reset every 30 days
- Limit of 1,000 requests/hour per test key
Go to Production
- Complete KYB (Know Your Business) verification in your dashboard
- Switch the base URL from
api.elyonpay.nettoapi.elyonpay.org - Verify that your redirect URLs (success / error) use HTTPS
- Configure your low balance alerts
Request Format
All requests and responses use JSON format. The Content-Type: application/json header is required for requests with a body.
Required Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer {votre_token_jwt} | Required |
Content-Type | application/json | Required (POST) |
Accept | application/json | Optional |
Idempotency-Key | UUID unique | Recommended |
Standard Response Format
{
"success": true,
"data": {
"id": "pay_1A2B3C4D5E6F",
"status": "pending",
"amount": 15000,
"currency": "XAF"
},
"meta": {
"request_id": "req_xxxxxxx",
"timestamp": "2024-03-24T10:15:00Z"
}
}Create a Payment
Generates a secure payment link. The customer is redirected to this link to choose their payment method (Mobile Money, bank card) and complete the transaction.
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | number | Required | Payment amount |
| user_lang | string | Required | Payment interface language (fr, en) |
| msisdn | string | Required | Phone number in international format: +237600000000 |
| success | string | Required | Redirect URL on success |
| error | string | Required | Redirect URL on error |
| webhook | string | Optional | HTTPS URL notified on each transaction state change (see the Webhooks section) |
| external_id | string | Optional | Your order reference, 1 to 64 characters, unique per merchant. Returned by GET /api/transactions/{uuid} (externalId) and in webhooks (data.external_id) |
| customer | object | Optional | Your end customer, as one block: { account_ref, name, email, phone, locale }. The same block is accepted on every pay-in endpoint; returned by GET /api/transactions/{uuid} (accountRef, accountName, customerEmail) and in webhooks (data.customer) |
| customer.account_ref | string | Optional | The customer's identifier on your side (subscriber, account, customer number…), 1 to 64 characters: a label we return to you in responses and webhooks |
| customer.name | string | Optional | Declared name of the customer, 128 characters at most. Shown in your merchant area; never used as a key. |
| customer.email | string | Optional | The customer's e-mail, 190 characters at most, validated (422 otherwise). Kept on the transaction for messages addressed to them |
| customer.phone | string | Optional | The customer's declared phone number, international format. Same as msisdn: either one is enough. At payment time the number actually charged replaces it |
| customer.locale | string | Optional | Language of the messages addressed to the customer (fr, en, en-GB…) |
| onbehalfof | string | number | Optional | Collect on behalf of a sub-merchant: its id or partner code. Platform merchants only (see the Platform Payments section) |
| fees | number | Optional | Platform commission to withhold (same unit as amount). If omitted, the sub-merchant then platform default fees apply |
player_ref and player_name are no longer accepted. Send customer.account_ref and customer.name. A request that still contains the old name is refused on every payment endpoint: 422 PLAYER_REF_REMOVED or PLAYER_NAME_REMOVED, naming its replacement.Response
{
"url": "https://app.elyonpay.org/t/CI032667e43abf11?success=https://www.success.com&error=https://www.error.com"
}Code Examples
cURL
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 15000,
"user_lang": "fr",
"msisdn": "+237600000001",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error",
"webhook": "https://yoursite.com/webhooks/elyonpay",
"external_id": "CMD-2026-0042",
"customer": {
"account_ref": "CLT-1001",
"name": "Jean Mballa",
"email": "jean@example.com"
}
}'Node.js / JavaScript
const response = await fetch('https://api.elyonpay.org/api/request-to-pay/payment/link', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'language': 'fr'
},
body: JSON.stringify({
amount: 15000,
user_lang: 'fr',
msisdn: '+237600000001',
success: 'https://yoursite.com/success',
error: 'https://yoursite.com/error',
webhook: 'https://yoursite.com/webhooks/elyonpay',
external_id: 'CMD-2026-0042',
customer: {
account_ref: 'CLT-1001',
name: 'Jean Mballa',
email: 'jean@example.com'
}
})
});
const data = await response.json();
console.log(data.url); // Payment link to redirect the customerPHP
$ch = curl_init('https://api.elyonpay.org/api/request-to-pay/payment/link');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'language: fr'
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 15000,
'user_lang' => 'fr',
'msisdn' => '+237600000001',
'success' => 'https://yoursite.com/success',
'error' => 'https://yoursite.com/error',
'webhook' => 'https://yoursite.com/webhooks/elyonpay',
'external_id' => 'CMD-2026-0042',
'customer' => [
'account_ref' => 'CLT-1001',
'name' => 'Jean Mballa',
'email' => 'jean@example.com'
]
])
]);
$response = json_decode(curl_exec($ch), true);
echo $response['url']; // Payment linkTransactions
Retrieve the complete history of your transactions: received payments, refunds, fees.
Returns a paginated list of all your transactions. You can also retrieve details of a specific transaction via its identifier.
Transaction Detail
curl -X GET https://api.elyonpay.org/api/transactions/1553 \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Accept: application/json"Response Example
{
"id": 1553,
"PSPid": "FR033169cbd9c1f1",
"uuid": "FR033169cbd9c1f1",
"creation_date": "2026-03-31T14:27:13+00:00",
"validation_date": null,
"delivery_date": null,
"amount": 100,
"totalCustomer": 100,
"state": "CREATED",
"ticket_type": "SMS_EMAIL",
"operation_code": "03",
"payer_phone_number": "+33782112127",
"beneficiary_phone_number": "+33782112127",
"payer_currency": "EUR",
"beneficiary_currency": "EUR",
"payer_country": "FR",
"beneficiary_country": "FR",
"payment": {
"name": "MOLLIE"
},
"customer": {
"phoneNumber": "+33782112127",
"email": "client@example.com"
},
"merchant": {
"id": 258,
"name": "Ma Boutique",
"city": "Paris",
"address": "10 Rue de la Paix",
"zipCode": "75002",
"type": "individual",
"country_code": "FR",
"country": "France"
},
"transactionStates": [
{
"state": "CREATED",
"date": "2026-03-31T14:27:14+00:00"
}
],
"commission_operateur": 0,
"commission_marchand": 0,
"commission_pays": 0,
"commission_customer": 0,
"deliveryPrice": 0
}Response Fields
| Field | Type | Description |
|---|---|---|
| id | integer | Internal transaction identifier |
| PSPid | string | Unique ElyonPay identifier (e.g. FR033169cbd9c1f1) |
| uuid | string | Unique identifier — same value as PSPid |
| creation_date | datetime | Creation date (ISO 8601 with timezone) |
| validation_date | datetime|null | Payment validation date — null if not yet validated |
| delivery_date | datetime|null | Delivery date — null if not applicable |
| amount | number | Transaction amount in the beneficiary's currency |
| totalCustomer | number | Total amount charged to the customer (fees included) |
| state | string | Current status: CREATED, PENDING, WAITING_FOR_PAYMENT, ACCEPTED, DELIVERED, REJECTED, DECLINED, CANCELLED |
| ticket_type | string | Notification type: SMS_EMAIL, SMS, EMAIL |
| operation_code | string | Internal operation code |
| payer_phone_number | string | Payer's phone number (international format) |
| externalId | string|null | Your order reference (external_id), as given at creation |
| accountRef | string|null | Your end customer's identifier (customer.account_ref), as given at creation |
| accountName | string|null | Declared name of the end customer (customer.name) |
| customerEmail | string|null | Declared e-mail of the end customer (customer.email) |
| beneficiary_phone_number | string | Beneficiary's phone number |
| payer_currency | string | Payer's currency (ISO 4217 code: EUR, XAF, XOF...) |
| beneficiary_currency | string | Beneficiary's currency |
| payer_country | string | Payer's country code (ISO 3166: FR, CM, CI...) |
| beneficiary_country | string | Beneficiary's country code |
| payment.name | string | Payment provider used (MOLLIE, MTN, ORANGE...) |
customer Object
| Field | Type | Description |
|---|---|---|
| phoneNumber | string | Customer's phone number |
| string | Customer's email address |
merchant Object
| Field | Type | Description |
|---|---|---|
| id | integer | Merchant identifier |
| name | string | Merchant or business name |
| city | string | Merchant's city |
| address | string | Postal address |
| zipCode | string | Zip code |
| type | string | Account type: individual, company |
| country_code | string | ISO 3166 country code (FR, CM, CI...) |
| country | string | Country name |
transactionStates Array
History of transaction status changes, from oldest to newest.
| Field | Type | Description |
|---|---|---|
| state | string | Status at that point (CREATED, PENDING, WAITING_FOR_PAYMENT, ACCEPTED, DELIVERED...) |
| date | datetime | Status change date |
Fees
| Field | Type | Description |
|---|---|---|
| commission_operateur | number | Telecom operator commission |
| commission_marchand | number | Merchant commission |
| commission_pays | number | Country commission |
| commission_customer | number | Commission applied to the customer |
| deliveryPrice | number | Delivery fees |
Custom Checkout
Custom Checkout lets you build your own payment UI while using ElyonPay APIs directly. Unlike the simple payment link (redirect), you keep full control over the user experience.
4-Step Flow
Fetch payment methods
Call GET /api/public/configuration/payments/{countryCode} to get available methods in the customer's country.
Display your checkout
Show the order summary and payment methods in your own UI. The customer picks how to pay.
Initiate payment
Send the direct payment request (Mobile Money or bank card) with the chosen method.
Poll for status
Query GET /api/transactions/{id} every 3–5 seconds until you get a terminal state (DELIVERED, FAILED, CANCELLED).
Sequence Diagram
sequenceDiagram
participant Customer
participant Merchant
participant ElyonPay
Customer->>Merchant: Browses products
Merchant->>ElyonPay: GET /api/public/configuration/payments/{country}
ElyonPay-->>Merchant: Available payment methods
Customer->>Merchant: Selects payment method
Merchant->>ElyonPay: POST /api/mobile-money/payment/request
ElyonPay-->>Merchant: PaymentResult (transactionId, state)
loop Poll every 3-5s (max 2 min)
Merchant->>ElyonPay: GET /api/transactions/{id}
ElyonPay-->>Merchant: Transaction state
end
Merchant->>Customer: Displays confirmation or error
Payment Methods
Dynamically retrieve the list of available payment methods in a given country. This endpoint is public — no authentication is required.
Path Parameter
| Parameter | Type | Required | Description |
|---|---|---|---|
| countryCode | string | Required | ISO 3166 country code — see table below |
Supported Country Codes
| Code | Country |
|---|---|
CM | Cameroon |
CI | Ivory Coast |
GA | Gabon |
FR | France |
TG | Togo |
BJ | Benin |
Response Example
{
"payments": [
{
"name": "ORANGE_MONEY",
"label": "Orange Money",
"type": "MOBILE_MONEY",
"available": true
},
{
"name": "MTN_MONEY",
"label": "MTN Mobile Money",
"type": "MOBILE_MONEY",
"available": true
},
{
"name": "STRIPE",
"label": "Carte bancaire (Visa / Mastercard)",
"type": "CARD",
"available": true
}
]
}curl https://api.elyonpay.org/api/public/configuration/payments/CMDirect Payment
Initiate a payment directly from your server without redirecting to an ElyonPay page. The customer stays on your site throughout the transaction.
Mobile Money
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | number | Required | Payment amount in local currency |
| user_lang | string | Required | User language (fr, en) |
| merchant_name | string | Required | Merchant name shown to the customer |
| merchant_id | integer | Required | Merchant identifier |
| currency | string | Required | ISO 4217 currency code (XAF, XOF, EUR...) |
| country_name | string | Required | Customer's country name |
| payment_method | string | Required | Must match the name field returned by GET /api/public/configuration/payments/{countryCode} |
| msisdn | string | Required | Customer phone number (international format) |
| transaction | string | Optional | External transaction reference (optional) |
cURL
curl -X POST https://api.elyonpay.org/api/mobile-money/payment/request \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-d '{
"amount": 15000,
"user_lang": "fr",
"merchant_name": "Ma Boutique",
"merchant_id": 207,
"currency": "XAF",
"country_name": "Cameroon",
"payment_method": "ORANGE_MONEY",
"msisdn": "+237690000001"
}'Node.js / JavaScript
const response = await fetch('https://api.elyonpay.org/api/mobile-money/payment/request', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: 15000,
user_lang: 'fr',
merchant_name: 'Ma Boutique',
merchant_id: 207,
currency: 'XAF',
country_name: 'Cameroon',
payment_method: 'ORANGE_MONEY',
customer_msisdn: '+237690000001'
})
});
const result = await response.json();
// Start polling result.data.uuid for statusPHP
$ch = curl_init('https://api.elyonpay.org/api/mobile-money/payment/request');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 15000,
'user_lang' => 'fr',
'merchant_name' => 'Ma Boutique',
'merchant_id' => 207,
'currency' => 'XAF',
'country_name' => 'Cameroon',
'payment_method' => 'ORANGE_MONEY',
'customer_msisdn' => '+237690000001'
])
]);
$response = json_decode(curl_exec($ch), true);
// Start polling $response['data']['uuid'] for statusBank Card
Same payload as the Mobile Money payment, with one exception: payment_method is not an operator here but the provider processing the card. Two additional parameters control the post-payment redirect.
Additional Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| payment_method | string | Required | MOLLIE or STRIPE — the provider processing the card. These are the only two accepted values: anything else is rejected with a 422. |
| success | string | Optional | Redirect URL after a successful payment. The customer will be redirected to {success}?id={uuid}&status=pending |
| error | string | Optional | Redirect URL after a failed or cancelled payment. The customer will be redirected to {error}?id={uuid}&status=cancelled |
If these parameters are not provided, the customer will be redirected to the default ElyonPay transaction page.
Important: never rely solely on the redirect URL (success/error) to confirm a payment. A user can manually modify the URL. Always verify the status server-side via GET /api/transactions/{uuid}.
Response — PaymentResult
{
"success": true,
"data": {
"PSPid": "tr_aHYQAw4rudrttLDwtdKTJ",
"PSPnotifyId": "tr_aHYQAw4rudrttLDwtdKTJ",
"PSPtxnId": "tr_aHYQAw4rudrttLDwtdKTJ",
"url": "https://www.mollie.com/checkout/credit-card/session/aHYQAw4rudrttLDwtdKTJ",
"uuid": "CM06306a438d0958",
"PSPMessage": null,
"PSPstatus": ""
}
}Response Fields
| Field | Type | Description |
|---|---|---|
| success | boolean | Whether the request succeeded |
| data.PSPid | string | Payment Service Provider identifier |
| data.PSPnotifyId | string | PSP notification identifier |
| data.PSPtxnId | string | PSP transaction identifier |
| data.url | string | Payment URL (card redirect if applicable) |
| data.uuid | string | Unique ElyonPay identifier — use this for polling |
| data.PSPMessage | string|null | Message returned by the PSP (null if none) |
| data.PSPstatus | string | Status returned by the PSP |
Status Polling
After initiating a direct payment, periodically poll the API to get the transaction result. Polling is the reference mechanism; optional webhooks are also available for real-time notifications.
Terminal States
| State | Result | Description |
|---|---|---|
| DELIVERED | Success | Payment confirmed, funds transferred |
| FAILED | Failure | Payment failed (insufficient balance, network error...) |
| DECLINED | Failure | Payment declined by the operator |
| CANCELLED | Failure | Cancelled by the customer or system |
| PENDING | Paid | Payment confirmed. Despite its name, this state means the money has been collected: you can fulfil the order. What it awaits is delivery, not payment. |
The word “pending” is misleading: with most payment providers it means “in progress, nothing decided”, whereas here it means the opposite — the payment has been collected. We are therefore renaming it to WAITING_FOR_DELIVERY, which says what it actually awaits.
Today: nothing changes. The API and webhooks still send state = "PENDING" and the transaction.pending event. Your existing integrations keep working unchanged.
To get ready: start accepting both values, PENDING and WAITING_FOR_DELIVERY, and both event names. Treat them as a confirmed payment, not as a transaction in progress. The switch will be announced in advance and will not happen before you have had time to migrate.
Complete Example — Node.js / Express
This example covers all 4 Custom Checkout steps: payment methods, display, direct payment and status polling.
const express = require('express');
const app = express();
app.use(express.json());
const API = 'https://api.elyonpay.org/api';
let token = ''; // Obtained via POST /api/login
// Helper: poll transaction status
async function pollStatus(txId, maxMs = 120000, intervalMs = 4000) {
const start = Date.now();
while (Date.now() - start < maxMs) {
const res = await fetch(`${API}/transactions/${txId}`, {
headers: { 'Authorization': 'Bearer ' + token }
});
const tx = await res.json();
const terminal = ['DELIVERED', 'FAILED', 'DECLINED', 'CANCELLED'];
if (terminal.includes(tx.state)) return tx;
await new Promise(r => setTimeout(r, intervalMs));
}
throw new Error('Polling timeout');
}
// Step 1 — Get payment methods for customer's country
app.get('/payment-methods/:country', async (req, res) => {
const methods = await fetch(
`${API}/public/configuration/payments/${req.params.country}`
).then(r => r.json());
res.json(methods);
});
// Steps 2-3-4 — Customer picks method, initiate payment, poll result
app.post('/pay', async (req, res) => {
const { payment_method, phone, merchant_id, amount } = req.body;
// Step 3 — Initiate direct payment
const payment = await fetch(`${API}/mobile-money/payment/request`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount,
user_lang: 'fr',
merchant_name: 'Ma Boutique',
merchant_id,
currency: 'XAF',
country_name: 'Cameroon',
payment_method,
customer_msisdn: phone
})
}).then(r => r.json());
// Step 4 — Poll for terminal state
try {
const tx = await pollStatus(payment.data.uuid);
if (tx.state === 'DELIVERED') {
res.json({ success: true, transaction: tx });
} else {
res.json({ success: false, state: tx.state, transaction: tx });
}
} catch (err) {
res.status(408).json({ success: false, error: 'Payment timeout' });
}
});
app.listen(3000);Delivery Proof
Additional fields, added to the payment calls you already make, to automatically build a dispute file when a cardholder claims a digital good or service was never delivered. No new endpoint to integrate: the fields are added to the payment you already send.
Where these fields will apply
On card payments, where a dispute is possible. Mobile Money and Cash payments are unaffected: they are irrevocable and cannot be disputed.
| Endpoint | Rail | Planned status |
|---|---|---|
| /stripe/payment/request | Card | Required |
| /bankcard/payment/request | Card | Required |
| /request-to-pay/payment/request | Card possible | Required |
| /request-to-pay/payment/initialize | Card possible | Required |
| /request-to-pay/payment/link | Card, paid later | Required |
| /mobile-money/payment/request, /cash/payment/request | Irrevocable | Accepted, never required |
Fields
All of them are added at the top level of the request body, next to amount, merchant_id, etc. — no nested block.
| Parameter | Type | Required | Description |
|---|---|---|---|
| external_id | string | Required | Your own order reference. This is what lets you reconcile our transactions with yours — we cannot invent it for you. |
| product_type | string (enum) | Required | What you're delivering: DIGITAL_DOWNLOAD, DIGITAL_ACCESS, SERVICE or PHYSICAL. Automatically determines the confirmation mode (see below) — you don't pick it separately. |
| service_description | string | Required | A short sentence describing what's sold. This is what the card network's dispute analyst will read. |
| terms.accepted_at | string (ISO 8601) | Required | Timestamp of your terms of sale being accepted by the customer. |
| terms.version | string | Required | The version of your terms that was accepted. |
| customer.email | string | Conditional | Payer's email. At least one of email or phone will be required; both can be provided. |
| customer.phone | string | Conditional | Payer's phone. On /stripe/payment/request and /bankcard/payment/request, can also be provided via the existing customer_msisdn field, which will keep working — you don't have to migrate before the activation date. |
| customer.account_ref | string | Optional | The customer's identifier on your side, 1 to 64 characters. This is the same customer block as on the payment link: it is already accepted today, together with customer.name, customer.email and customer.locale. |
| customer.name | string | Optional | Declared name of the customer, 128 characters at most. |
On /request-to-pay/payment/request, /initialize and /link, the payer's phone is already known (the transmitted msisdn): nothing to add to satisfy the email/phone condition, unless you also want to provide the email.
product_type values
| Valeur | Example | Confirmation mode |
|---|---|---|
| DIGITAL_DOWNLOAD | Movie, PDF, downloadable course | Signed link — the click is the confirmation |
| DIGITAL_ACCESS | Subscription, access opened in a third-party system | Validation by your own system |
| SERVICE | Service performed | QR code shown at delivery |
| PHYSICAL | Physical good handed over in person | QR code shown at delivery |
Payload preview
On /bankcard/payment/request, for illustration only — the last five fields currently have no effect (see the callout above).
{
"amount": 15000,
"merchant_id": 4821,
"operation_code": "33",
"currency": "XAF",
"payment_method": "MOLLIE",
"user_lang": "fr",
"customer_msisdn": "+24106000000",
"external_id": "CMD-88213",
"product_type": "DIGITAL_DOWNLOAD",
"service_description": "Formation Trésorerie PME, 4 modules vidéo",
"customer": { "email": "jean@example.com", "phone": "+24106000000" },
"terms": { "accepted_at": "2026-09-06T10:00:00Z", "version": "cgv-2026-03" }
}Official SDKs & Plugins
Official SDKs and plugins are available to integrate ElyonPay into your projects. They automatically handle authentication, API calls and payment status verification.
Official PHP SDK for integrating the ElyonPay API. Zero external dependencies — uses only native cURL.
Official WooCommerce plugin. Accept Mobile Money and bank cards directly from your WordPress store.
Official SDK for integrating Custom Checkout into your React and Vue.js apps. Includes hooks, composables and automatic polling.
Integration Flow
ElyonPay uses a 6-step model based on a payment link and active status verification (pull model). No webhook infrastructure is required — optional webhooks are however available as a complement.
Get a payment link
Your backend calls POST /api/request-to-pay/payment/link with the amount and redirect URLs (success / error). The API returns a unique payment URL.
Display the payment iframe
Redirect the customer to the returned URL, or embed it in an iframe. The customer chooses their payment method (Mobile Money, bank card) and enters their details.
Payment validation
The customer confirms the payment on the operator side (USSD code for Mobile Money, 3D Secure for cards). ElyonPay processes the transaction.
Redirect to your backend
Once the payment is processed, the customer is redirected to your success or error URL depending on the result. The transaction UUID is passed in the redirect URL.
Fetch the transaction status
Your backend calls GET /api/transactions/{uuid} to retrieve the definitive transaction status. This server-side check is essential — never rely solely on the redirect URL.
Display confirmation to the customer
Based on the returned status (DELIVERED, REJECTED, CANCELLED...), display the appropriate confirmation or error page to your customer.
Diagram — Payment Initiation
sequenceDiagram
participant Customer
participant Merchant
participant ElyonPay
Customer->>Merchant: Finalizes cart
Customer->>Merchant: Selects "Pay"
Merchant->>ElyonPay: POST /api/login
ElyonPay-->>Merchant: Returns JWT token
Merchant->>ElyonPay: POST /api/request-to-pay/payment/link
ElyonPay-->>Merchant: Returns payment URL
Merchant->>Customer: Displays payment page (iframe or redirect)
Customer->>ElyonPay: Selects payment method
Customer->>ElyonPay: Completes transaction
ElyonPay-->>Customer: Shows confirmation popup
Diagram — Payment Result
sequenceDiagram
participant Customer
participant Merchant
participant ElyonPay
Customer->>ElyonPay: Transaction completed
alt Successful payment
ElyonPay->>Merchant: Redirect to success URL
else Failed payment
ElyonPay->>Merchant: Redirect to error URL
end
Merchant->>ElyonPay: GET /api/transactions/[ID_TRANSACTION]
ElyonPay-->>Merchant: Returns transaction status
Merchant->>Merchant: Updates order status
Merchant->>Customer: Shows order confirmation
Implementation example (steps 1 → 5)
Node.js / Express
// 1. Create payment link
app.post('/checkout', async (req, res) => {
const response = await fetch('https://api.elyonpay.org/api/request-to-pay/payment/link', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'language': 'fr'
},
body: JSON.stringify({
amount: req.body.amount,
user_lang: 'fr',
msisdn: req.body.phone,
success: 'https://yoursite.com/payment/done',
error: 'https://yoursite.com/payment/error'
})
});
const { url } = await response.json();
res.redirect(url); // 2. Redirect customer to payment page
});
// 4 → 5. Handle redirect & verify status
app.get('/payment/done', async (req, res) => {
// The transaction uuid is available from your order context
const tx = await fetch(`https://api.elyonpay.org/api/transactions/${uuid}`, {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json());
if (tx.state === 'DELIVERED') {
// 6. Show confirmation
res.render('success', { transaction: tx });
} else {
res.render('pending', { transaction: tx });
}
});Mobile Money
ElyonPay unifies 5 African Mobile Money networks under a single API. Each network has its own operator codes and country coverage.
Supported Networks
| Method Code | Network | Countries | Currencies |
|---|---|---|---|
mtn_mobile_money | MTN MoMo | CM, NG, CI, GH, UG | XAF, NGN, XOF |
orange_money | Orange Money | CM, CI, SN, ML | XAF, XOF |
wave | Wave | SN, CI, BF, ML | XOF |
moov_money | Moov Africa | BJ, TG, BF, NE | XOF |
airtel_money | Airtel Money | KE, TZ, UG, ZM, CD | KES, TZS, UGX, CDF |
Coverage: Cameroon, Nigeria, Ivory Coast, Ghana, Uganda, Senegal, Mali, Burkina Faso, Benin, Togo, Niger, Kenya, Tanzania, Zambia, Democratic Republic of Congo.
Bank Cards
Accept Visa and Mastercard payments with 3D Secure, tokenization and PCI DSS level 1 compliance.
Test Cards (Sandbox)
| Card Number | Expiry | CVV | Behavior |
|---|---|---|---|
4242 4242 4242 4242 | 12/28 | 123 | Successful payment |
4000 0000 0000 0002 | 12/28 | 123 | Card declined |
4000 0000 0000 3220 | 12/28 | 123 | 3D Secure required |
5555 5555 5555 4444 | 12/28 | 123 | Mastercard success |
Platform Payments (Split)
A platform (marketplace) can collect a payment on behalf of its merchants. ElyonPay automatically splits the funds between the sub-merchant and the platform: the sub-merchant receives the net amount and the platform withholds its commission, in a single customer-facing transaction. A split can also target several beneficiaries at once, with the splits parameter.
Step 1 — Create your sub-merchants
Before collecting on behalf of a merchant, you must register it as a sub-merchant of your platform. Two methods are available:
- From the dashboard — in the Sub-merchants section of your platform dashboard, add a merchant via the form (identity, country, default fees). Ideal for manual management.
- Via the API — create your sub-merchants programmatically with the dedicated endpoint below. Ideal for automated onboarding.
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Sub-merchant business name |
| country_code | string | Required | ISO 3166 country code (e.g. CM) |
| city | string | Required | City |
| address | string | Required | Address |
| type | string | Required | Business type (e.g. RETAIL) |
| owner_email | string | Required | Account owner's email |
| owner_phone_number | string | Required | Owner's phone number |
| owner_first_name | string | Required | Owner's first name |
| owner_last_name | string | Required | Owner's last name |
| owner_password | string | Required | Initial password for the owner account |
| default_marketplace_fee_percent | number | Optional | Default commission (%) withheld on this sub-merchant's payments. Otherwise the platform default fees apply |
curl -X POST https://api.elyonpay.org/api/merchant/sub-merchants \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Boutique ABC",
"country_code": "CM",
"city": "Douala",
"address": "123 Rue Commerce",
"type": "RETAIL",
"owner_email": "owner@boutique-abc.com",
"owner_phone_number": "+237600000000",
"owner_first_name": "Jean",
"owner_last_name": "Dupont",
"owner_password": "s3cur3-p4ssword",
"default_marketplace_fee_percent": 5.00
}'{
"merchant_id": 725,
"uuid": "a1b2c3d4-...",
"name": "Boutique ABC",
"owner_email": "owner@boutique-abc.com",
"owner_phone": "+237600000000",
"kyb_required": true,
"default_fee_percent": "5.00"
}Note the returned merchant_id: it is the identifier you will pass in onbehalfof to collect for this merchant. The sub-merchant must have a configured payout account (to receive the funds); its KYB verification may also be required before going live.
Manage your sub-merchants
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/merchant/sub-merchants | List your sub-merchants |
| GET | /api/merchant/sub-merchants/{id} | Get a sub-merchant |
| PATCH | /api/merchant/sub-merchants/{id}/fees | Update its default commission |
Step 2 — Collect on behalf of a sub-merchant
Create the payment as usual (see Create a Payment), adding the onbehalfof parameter with the sub-merchant's id or partner code. ElyonPay then creates a split payment.
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 15000,
"user_lang": "fr",
"msisdn": "+237600000001",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error",
"webhook": "https://yoursite.com/webhooks/elyonpay",
"onbehalfof": 725
}'How the commission is computed
The commission withheld by the platform is resolved in this priority order:
- The
feesparameter passed in the call (exact amount, takes priority when present). - Otherwise, the sub-merchant's default commission (default_marketplace_fee_percent).
- Otherwise, the default commission configured on the platform.
- If no source is available, the request is rejected (400 error).
The commission must be positive and strictly lower than the amount, and respect the caps defined on the platform. The sub-merchant receives amount − commission, the platform receives the commission.
Sub-merchant collecting directly
A sub-merchant can also initiate a payment itself, using its own API key, without going through the platform. In that case, do not add onbehalfof: the split to the parent platform applies automatically, with the same commission rules.
Splitting across several beneficiaries
onbehalfof names a single sub-merchant. To split one collection across several beneficiaries, send splits: an array of { merchant, percent } entries, where merchant is the beneficiary's id or partner code. Available on payment links (request-to-pay) and on card payments. Cash payments are not split.
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 15000,
"user_lang": "fr",
"msisdn": "+237600000001",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error",
"splits": [
{ "merchant": 725, "percent": 60 },
{ "merchant": "PART-EGT-00042", "percent": 25 }
]
}'The payer pays 15,000 XAF: the first beneficiary receives 9,000, the second 3,750, and the platform keeps the remainder, 2,250. That remainder is a subtraction rather than a percentage, which makes the split exact to the unit with no rounding rule: if three 33.33% shares leave an orphan unit, it stays with the platform.
The platform must always keep a strictly positive share. This is not a commercial rule: its share is what carries the real cost of the collection. Without it, that cost would fall on a beneficiary who did not choose the payment method.
What amount means: split_mode
A single question determines the whole calculation: is amount what the payer pays, or what the recipients receive?
| split_mode | amount | When to use it |
|---|---|---|
| SUBTRACTIVEdefault | What the payer pays. Shares are taken out of it and their sum equals the amount. | The common case: you quote a price to your customer, you collect it, you split it. |
| ADDITIVE | What the principal recipient receives. Shares are added on top and the payer pays more. | An amount owed to a third party that must not bear your fees: a tax, a regulatory levy. The recipient gets their nominal amount to the last unit, whatever commercial terms apply around it. |
In ADDITIVE mode, two extra fields are needed. principal names who receives the amount itself — onbehalfof also works, since it already says on whose behalf you are collecting. And your platform must appear in splits with a share of its own: that share absorbs the gap between the estimated and the real rail cost, which is only known at settlement.
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 25000,
"split_mode": "ADDITIVE",
"principal": 931,
"user_lang": "fr",
"msisdn": "+237600000001",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error",
"splits": [
{ "merchant": 725, "percent": 8 }
]
}'Beneficiary 931 receives exactly 25,000 XAF. Platform 725 takes 8% of that amount, 2,000 XAF, as a surcharge. The payer settles 27,000 XAF, plus ElyonPay commissions, which are charged to them as well. In additive mode, no beneficiary other than your platform bears our fees: each one receives their nominal share.
Split rules
- The calling account must be a platform, and every beneficiary must belong to it.
- Every beneficiary must have a payout account configured, otherwise their share could never be paid out.
- A beneficiary may appear only once: add up their percentages yourself.
- Each percent must be strictly greater than 0, and the resulting share must be worth at least one monetary unit after rounding.
- In SUBTRACTIVE mode the platform is not listed in splits: it receives the remainder. In ADDITIVE mode it must be listed.
- If a commission cap is configured on your platform, it applies to its share, exactly as it does to onbehalfof fees.
Error Codes
| Situation | Code |
|---|---|
| The calling account is not a platform, or the sub-merchant belongs to another platform | 403 |
| Sub-merchant not found (id or partner code) | 404 |
| Invalid commission (negative, ≥ amount, cap exceeded) or no payout account configured | 400 |
| Empty splits, percent ≤ 0, or a share rounding down to zero | 400 |
| The same beneficiary appears twice in splits | 400 |
| The shares leave nothing to the platform, which carries the pay-in cost | 400 |
| ADDITIVE mode without principal, or without a share for the platform itself | 400 |
| A splits beneficiary does not belong to the calling platform | 403 |
Payouts
Once a payment is delivered, ElyonPay pays out your share. When that money leaves our accounts depends on the payout mode set on yours.
The four payout modes
| Mode | When the funds leave | What it means for you |
|---|---|---|
| INSTANTdefault | As soon as possible after the payment is delivered. | You are paid as fast as possible, at the instant fee tier, which is the more expensive one. |
| DELAYED | On the payment provider's settlement date, automatically. | The wait has an end date known in advance, and nobody has to do anything for it to arrive. Standard fee tier. |
| MANUAL | When an ElyonPay operator releases the payout. | For accounts whose payouts are reviewed before leaving. Standard fee tier. |
| WALLET | Never per sale. The net amount is credited to your balance with us, and only leaves through your own payout requests. | For accounts whose collections are their customers' money, paid back to them later. Standard fee tier. |
A bank transfer collection always takes the standard tier, even on an INSTANT account: a transfer does not settle instantly, whatever the mode.
Wallet-mode account: checking your balance
A wallet-mode account has a dedicated endpoint, callable with its API key just like its collections.
curl -X GET https://api.elyonpay.org/api/wallet/balance \
-H "Authorization: Bearer <your_jwt_token>"{
"currency": "XAF",
"balance": 1250000,
"pending_release": 300000,
"available": 950000,
"release_delay_hours": 72,
"as_of": "2026-10-20T17:12:04+00:00"
}| Field | Meaning |
|---|---|
| currency | The balance currency, that of your account's country. |
| balance | Everything we hold for you, available or not. |
| pending_release | The part of that balance from deposits too recent to be paid out. |
| available | What you can pay out right now: balance − pending_release. |
| release_delay_hours | The availability delay applied to your account, in hours. |
| as_of | When the figures were computed, in ISO 8601 format. |
The availability delay is 72 hours in production. It is set to 0 in the sandbox, so you can deposit and pay out within the same test session.
The endpoint answers 404 if your account is not in wallet mode, and 403 if the key does not belong to a merchant. Note: on a wallet-mode account, customer.account_ref is required on every collection, otherwise the deposit is refused with 422 PLAYER_REF_REQUIRED — it ties the deposit to the customer, and later the payout to the number that deposited.
Webhooks
ElyonPay can notify your server in real time whenever a transaction changes state, via a server-to-server HTTP POST request. Webhooks are optional: they complement the pull model (polling) but do not replace it.
Enabling Webhooks
No global configuration is needed: the webhook URL is provided per transaction, via the optional webhook parameter when creating the payment. The URL must be a valid, publicly reachable HTTPS URL.
curl -X POST https://api.elyonpay.org/api/request-to-pay/payment/link \
-H "Authorization: Bearer <your_jwt_token>" \
-H "Content-Type: application/json" \
-H "language: fr" \
-d '{
"amount": 15000,
"user_lang": "fr",
"msisdn": "+237600000000",
"success": "https://yoursite.com/success",
"error": "https://yoursite.com/error",
"webhook": "https://yoursite.com/webhooks/elyonpay"
}'Notified Events
A notification is sent each time the transaction reaches one of the following states:
| Event | State | Description |
|---|---|---|
| transaction.pending | PENDING | Payment initiated, awaiting confirmation |
| transaction.delivered | DELIVERED | Payment confirmed, funds transferred to the merchant |
| transaction.declined | DECLINED | Payment declined by the operator |
| transaction.cancelled | CANCELLED | Payment cancelled by the customer or the system |
Notification Format
Each notification is a JSON POST request, sent with the following headers:
| Header | Description |
|---|---|
| Content-Type | Always application/json |
| X-Mip-Event | Event name (e.g. transaction.delivered) |
| X-Mip-Delivery | Unique notification identifier — use it for deduplication |
| X-Mip-Attempt | Attempt number (1 to 5) |
Payload Example
{
"event": "transaction.delivered",
"created_at": "2026-07-08T18:45:12+02:00",
"data": {
"transaction": "CI032667e43abf11",
"state": "DELIVERED",
"amount": 15000,
"currency": "XAF",
"operation_code": "OP-2026-000123",
"merchant_id": 42,
"external_id": "CMD-2026-0042",
"customer": {
"account_ref": "CLT-1001",
"name": "Jean Mballa",
"email": "jean@example.com",
"phone": "+237670000001"
},
"creation_date": "2026-07-08T18:40:03+02:00",
"validation_date": "2026-07-08T18:44:57+02:00",
"delivery_date": "2026-07-08T18:45:10+02:00"
}
}The payload echoes your own references when you provided them at creation: external_id (your order) and the customer block (your end customer: account_ref, name, email; phone is the number actually charged). The keys are always present, null otherwise.
For marketplace transactions (payment split between several merchants), the payload also contains a data.split array listing each merchant's share (merchant_id, amount) — see Platform Payments.
Acknowledgement and Retries
Your server must respond with a 2xx HTTP status code within 10 seconds. Any other response (4xx, 5xx, timeout, network error) is considered a failure and triggers a retry according to the following schedule:
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate (on state change) |
| 2 | 3 seconds |
| 3 | 30 seconds |
| 4 | 60 seconds |
| 5 | 90 seconds |
After 5 unsuccessful attempts, the notification is permanently abandoned. The transaction status always remains available via GET /api/transactions/{uuid}.
Receiver Example (Node.js / Express)
app.post('/webhooks/elyonpay', express.json(), async (req, res) => {
// 1. Acknowledge immediately — heavy processing must happen async
res.sendStatus(200);
const event = req.headers['x-mip-event']; // e.g. "transaction.delivered"
const deliveryId = req.headers['x-mip-delivery']; // unique per notification
// 2. Deduplicate: the same delivery may be received more than once
if (await alreadyProcessed(deliveryId)) return;
// 3. Verify the state server-side before acting on it
const { transaction } = req.body.data;
const status = await fetch(
'https://api.elyonpay.org/api/transactions/' + transaction,
{ headers: { Authorization: 'Bearer ' + token } }
).then((r) => r.json());
if (status.state === 'DELIVERED') {
await fulfillOrder(transaction);
}
});Best Practices
- Respond quickly — Return the 2xx status before any long processing: beyond 10 seconds, the notification is considered failed and will be retried.
- Deduplicate — Store the X-Mip-Delivery header and ignore already-processed notifications — retries can cause duplicates.
- Verify server-side — Use the webhook as a signal, then confirm the state via GET /api/transactions/{uuid} before any business action.
- Keep polling as a safety net — If your server is unavailable for more than ~3 minutes, the notification is abandoned: polling remains the source of truth.
Wallet payouts
In wallet mode, your customers' deposits are not paid out to you one by one: they credit your balance at ElyonPay. You then move money out of it through the API, to your customers' Mobile Money (a withdrawal, a refund) or to your own account (your margin). This mode is meant for merchants who hold their customers' money and pay it back later.
How it works
- Deposit. Your customer pays as for any payment (see Create a payment), with
customer.account_refif you want to find their deposits again. Once the operator confirms the payment, its amount net of fees is credited to your balance. - Availability. A deposit can be paid out 72 hours after it is credited in production, immediately in sandbox. The delay in force is returned by
GET /api/wallet/balance(release_delay_hours). - Payout. You name the Mobile Money number to pay. It must have paid you: a confirmed deposit from that number. ElyonPay pays it with the operator of that deposit, Orange Money or MTN MoMo.
- Amount. It leaves your balance as soon as you request the payout, and only comes back if the operator refuses the transfer (status
FAILED). The amount you request is exactly the amount sent: fees were taken at deposit.
The number to pay
ElyonPay keeps no record of your customers: each payout states where the money goes, and we only check that this number has already paid you. Money only goes back to a number that paid you. A customer who changes number deposits from the new one; once that deposit is confirmed, that number is payable too. A card payment makes no number payable.
account_ref then redirects nothing: you choose the number.Authentication
Moving money out requires your API key, and nothing else: creating a payout, sending a batch or retrying with a dashboard session or a JWT returns 403 API_KEY_REQUIRED. The balance and the journal can be read with the key or with the account holder's session.
Authorization: Bearer pk_live_...Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/wallet/balance | Balance, pending, available |
| POST | /api/wallet/payouts | Create a payoutAPI key |
| POST | /api/wallet/payouts/batch | Send a batch of payoutsAPI key |
| GET | /api/wallet/payouts | Journal, filtered and paginated |
| GET | /api/wallet/payouts/{id|reference} | One payout |
| POST | /api/wallet/payouts/{id|reference}/retry | Retry a FAILED payoutAPI key |
Reading the balance
{
"currency": "XAF",
"balance": 1250000,
"pending_release": 300000,
"available": 950000,
"release_delay_hours": 72,
"as_of": "2026-11-02T17:12:04+00:00"
}| Field | Description |
|---|---|
| balance | What we hold for you. |
| pending_release | Deposits credited less than release_delay_hours ago, not yet payable. |
| available | What you can pay out now: balance − pending_release. |
| release_delay_hours | Delay before a deposit becomes payable. |
| as_of | Time of the computation, ISO 8601. |
Amounts are integers in the currency unit (XAF: the franc). An account that is not in wallet mode gets a 404.
Creating a payout
| Parameter | Type | Required | Description |
|---|---|---|---|
| reference | string | Required | Your unique identifier for the payout, 1 to 64 characters. It is your idempotency key: the same request replayed under the same reference returns the existing payout instead of creating a second one. |
| beneficiary_type | string | Optional | CUSTOMER (default) for a customer, MERCHANT for your margin, paid to the payout account registered with ElyonPay. |
| msisdn | string | Conditional | Required for CUSTOMER: the Mobile Money number to pay, in any form (+237 6…, 2376…, 6…). It must have made a confirmed deposit to you; it is paid with the operator of that deposit. |
| account_ref | string | Optional | Your identifier for the customer, at most 64 characters: a label for your journal and filters, never what decides where the money goes. |
| beneficiary_name | string | Optional | Beneficiary name, at most 128 characters. Otherwise, the name declared with the deposit from that number. |
| amount | integer | Required | Amount, strictly positive integer, in the currency unit. |
| description | string | Optional | At most 255 characters. Depending on the operator, shown in the SMS the customer receives. |
| allow_duplicate | boolean | Optional | true to confirm a detected duplicate (409 DUPLICATE_SUSPECTED). |
| webhook | string | Optional | https URL, at most 500 characters, that will receive the payout's outcome (see below). |
curl -X POST https://api.elyonpay.org/api/wallet/payouts \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"reference": "PAY-2026-11-000123",
"msisdn": "+237670000101",
"account_ref": "CLT-1001",
"amount": 25000,
"description": "Withdrawal of 02/11",
"webhook": "https://yoursite.com/elyonpay/payouts"
}'Response 201 when the payout is created, 200 when the same request is replayed: after a timeout, replay safely. A reference already taken by a different request is refused (409 REFERENCE_REUSED).
{
"id": "8a6eae66-4c7d-4c96-8683-b56e895f4e28",
"reference": "PAY-2026-11-000123",
"batch_ref": null,
"beneficiary_type": "CUSTOMER",
"account_ref": "CLT-1001",
"beneficiary_name": "Jean Mballa",
"msisdn": "237670000101",
"payment_method": "MTN_MONEY",
"amount": 25000,
"currency": "XAF",
"description": "Withdrawal of 02/11",
"status": "QUEUED",
"attempt": 1,
"failure_reason": null,
"operator_reference": null,
"created_at": "2026-11-02T20:29:10+00:00",
"updated_at": "2026-11-02T20:29:10+00:00",
"completed_at": null
}| Field | Description |
|---|---|
| id | ElyonPay identifier of the payout. |
| msisdn, payment_method | The number paid, digits only with the country code, and its operator (ORANGE_MONEY or MTN_MONEY), taken from its deposit. |
| account_ref | Your customer label, if you gave one. |
| beneficiary_name | The name in the request, otherwise the one declared at deposit (customer.name). |
| status | See Lifecycle below. |
| attempt | Attempt number: 1, then +1 on each retry. |
| failure_reason | Why a payout failed or is under review. |
| operator_reference | Transfer reference at the operator, once sent. |
| completed_at | Time of the final outcome (SUCCEEDED or FAILED). |
Sending a batch
For end-of-period payments. Each element of items has the same fields as a single payout. batch_ref (at most 64 characters) groups the lines in the journal; the batch webhook applies to every line, a line's own webhook wins.
{
"batch_ref": "OCT-2026",
"webhook": "https://yoursite.com/elyonpay/payouts",
"items": [
{ "reference": "OCT-2026-0001", "msisdn": "+237670000101", "account_ref": "CLT-1001", "amount": 12000 },
{ "reference": "OCT-2026-0002", "msisdn": "+237690000102", "account_ref": "CLT-1002", "amount": 40000 },
{ "reference": "OCT-2026-MARGE", "beneficiary_type": "MERCHANT", "amount": 300000 }
]
}Each line is checked and reserved on its own, with its own result: CREATED, EXISTING (already created, batch replayed) or REFUSED with its error. Two refusals apply to the whole batch and create nothing: too many lines (BATCH_TOO_LARGE), or a total of new lines above the available balance (BATCH_EXCEEDS_BALANCE). A batch replayed after a network failure creates nothing new.
{
"batch_ref": "OCT-2026",
"results": [
{ "index": 0, "reference": "OCT-2026-0001", "result": "CREATED", "payout": { "id": "3f0c...", "status": "QUEUED" } },
{ "index": 1, "reference": "OCT-2026-0002", "result": "REFUSED", "error": { "error_code": "MSISDN_NOT_ELIGIBLE", "message": "..." } },
{ "index": 2, "reference": "OCT-2026-MARGE", "result": "CREATED", "payout": { "id": "9b27...", "status": "QUEUED" } }
],
"summary": { "total": 3, "created": 2, "existing": 0, "refused": 1 }
}The response is 200 even when some lines are refused: read each line's result.
Tracking your payouts
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | QUEUED, PROCESSING, SUCCEEDED, FAILED or ON_HOLD. |
| account_ref | string | Optional | The payouts carrying this customer label. |
| batch_ref | string | Optional | The lines of a batch. |
| from, to | ISO 8601 | Optional | Creation period; to is excluded. |
| limit, offset | integer | Optional | Pagination: limit from 1 to 200 (50 by default). |
curl "https://api.elyonpay.org/api/wallet/payouts?status=FAILED&from=2026-10-01&to=2026-11-01&limit=50&offset=0" \
-H "Authorization: Bearer pk_live_..."
{ "items": [ { ... } ], "limit": 50, "offset": 0 }Most recent first. A payout can also be read on its own, by its id or by your reference: GET /api/wallet/payouts/PAY-2026-11-000123. It is the source of truth: when in doubt (missed webhook, timeout), read it again.
Retrying a payout
Only a FAILED payout can be retried (otherwise 409 NOT_RETRYABLE). It keeps your reference, goes back to QUEUED with a new attempt, and the amount is reserved on your balance again: balance and limits are checked again. It goes to the number in the request. Response 202 with the payout.
Lifecycle
| Status | Meaning | Balance |
|---|---|---|
QUEUED | Accepted, waiting to be sent to the operator. | Reserved |
PROCESSING | Sent to the operator, waiting for its answer. | Reserved |
SUCCEEDED | Paid. Final status. | Paid out |
FAILED | Refused by the operator. Final status, unless retried. | Returned |
ON_HOLD | Under review at ElyonPay: the operator gave no certain answer, or a check stopped the sending. We check with the operator and settle it: paid, returned or sent again. | Reserved |
Payout webhooks
With a webhook in the request, ElyonPay sends a JSON POST on each outcome. Nothing is sent for QUEUED or PROCESSING.
| Event | When | Balance |
|---|---|---|
payout.succeeded | The operator paid, or our review confirmed it. | The money is out |
payout.failed | The operator refused, or our review found nothing was sent. | Amount returned, retry possible |
payout.on_hold | The payout goes under review. | Amount still reserved |
A payout under review later gets a second notification, payout.succeeded or payout.failed. data is the full payout object, plus merchant_id (your partner code).
{
"event": "payout.succeeded",
"created_at": "2026-11-02T20:36:14+00:00",
"data": {
"id": "8a6eae66-4c7d-4c96-8683-b56e895f4e28",
"reference": "PAY-2026-11-000123",
"account_ref": "CLT-1001",
"amount": 25000,
"currency": "XAF",
"status": "SUCCEEDED",
"operator_reference": "5b1f0c2e-8d3a-4c47-9e21-3f6a7d2b9c10",
"completed_at": "2026-11-02T20:36:14+00:00",
"merchant_id": "PART-EGT-00042"
}
}| Header | Description |
|---|---|
| X-Mip-Event | The event. |
| X-Mip-Delivery | Notification identifier, the same across attempts: deduplicate on it. |
| X-Mip-Attempt | Attempt number. |
Checking with ElyonPay
The webhook notifies you, it is not proof: it is not signed. Before acting, read the payout again by its data.id with your API key, and decide on the status the API returns. A notification that did not come from us then only causes a read.
app.post('/elyonpay/payouts', express.json(), async (req, res) => {
res.sendStatus(200); // answer quickly, process afterwards
const { data } = req.body;
const answer = await fetch(
'https://api.elyonpay.org/api/wallet/payouts/' + encodeURIComponent(data.id),
{ headers: { Authorization: 'Bearer ' + process.env.ELYONPAY_API_KEY } }
);
const payout = await answer.json();
// Only the status read from ElyonPay counts.
if (payout.status === 'SUCCEEDED') await markPayoutPaid(payout.reference);
if (payout.status === 'FAILED') await markPayoutFailed(payout.reference);
});Answer with a 2xx code. Otherwise we try again four times, after 3 s, 30 s, 60 s and 90 s. A missed notification changes nothing to the payout: read it again through the API.
Checks and limits
Every request is checked before anything is reserved, then checked again when it is sent (account under review):
| Check | Refusal |
|---|---|
| Amount at most the per-payout limit | AMOUNT_ABOVE_LIMIT |
| Number given, that has paid you, with Orange Money or MTN MoMo | MSISDN_REQUIRED, MSISDN_NOT_ELIGIBLE, RAIL_NOT_SUPPORTED |
| Amount at most the available balance | INSUFFICIENT_FUNDS |
| Day's total at most the wallet daily limit | DAILY_LIMIT_EXCEEDED |
| Day's total to this number at most the daily limit per number | DESTINATION_DAILY_LIMIT_EXCEEDED |
| Not the same amount to the same number within the duplicate window | DUPLICATE_SUSPECTED |
Your limits (per payout, per day, per number and per day, duplicate window, lines per batch) are set in your contract and shown in your dashboard. The day is the calendar day in Douala. A FAILED payout no longer counts towards the limits; a retry counts on the day it is made.
Errors
The wallet API refusals have their own format: a stable error_code, a readable message, and the useful details at the same level.
{
"error_code": "INSUFFICIENT_FUNDS",
"message": "The available wallet balance does not cover this payout.",
"available": 950000,
"pending_release": 300000
}| Code | HTTP | Description |
|---|---|---|
| API_KEY_REQUIRED | 403 | Creating, batching or retrying without the merchant API key (dashboard session, JWT). |
| OWNER_ONLY | 403 | Payouts read by a user other than the account holder (a cashier, for instance). |
| MERCHANT_BLOCKED | 403 | Your account is under review: payouts are suspended. Contact us. |
| NOT_A_WALLET | 404 | The account is not in wallet mode. |
| NOT_FOUND | 404 | No payout under this id or reference. |
| INVALID_JSON | 400 | The body is not a JSON object (or, for a batch, has no items list). |
| STATUS_INVALID, FROM_INVALID, TO_INVALID | 400 | Unreadable journal filter. |
| REFERENCE_REQUIRED, REFERENCE_INVALID | 422 | reference missing, empty or longer than 64 characters. |
| AMOUNT_INVALID | 422 | amount is not a strictly positive integer. |
| AMOUNT_ABOVE_LIMIT | 422 | Amount above the per-payout limit (detail: max_amount). |
| BENEFICIARY_TYPE_INVALID | 422 | beneficiary_type other than CUSTOMER or MERCHANT. |
| MSISDN_REQUIRED | 422 | No msisdn for a payout to a customer. |
| ACCOUNT_REF_INVALID, BENEFICIARY_NAME_INVALID | 422 | account_ref or beneficiary_name too long, or with a control character. |
| DESCRIPTION_INVALID, BATCH_REF_INVALID, WEBHOOK_INVALID | 422 | Field too long, control character, or a webhook that is not an https URL. |
| MSISDN_NOT_ELIGIBLE | 422 | This number has never paid you: no confirmed Mobile Money deposit from it (detail: msisdn). |
| RAIL_NOT_SUPPORTED | 422 | The account to pay is neither Orange Money nor MTN MoMo. |
| MERCHANT_ACCOUNT_MISSING | 422 | Margin payout without a registered payout account. |
| INSUFFICIENT_FUNDS | 422 | The available balance does not cover the amount (details: available, pending_release). |
| DAILY_LIMIT_EXCEEDED | 422 | Wallet daily limit reached (details: daily_limit, used_today). |
| DESTINATION_DAILY_LIMIT_EXCEEDED | 422 | Daily limit for this number reached (details: destination_daily_limit, used_today, msisdn). |
| DUPLICATE_SUSPECTED | 409 | Same amount to the same number within the duplicate window (details: duplicate_of, duplicate_created_at). Resend with allow_duplicate: true if intended. |
| REFERENCE_REUSED | 409 | This reference already holds a different payout (detail: payout). |
| REFERENCE_DUPLICATED_IN_BATCH | 409 | The same reference appears twice in the batch. |
| NOT_RETRYABLE | 409 | Only a FAILED payout can be retried (detail: status). |
| BATCH_EMPTY, INVALID_ITEM | 422 | Batch without lines, or a line that is not an object. |
| BATCH_TOO_LARGE | 422 | Too many lines in the batch (detail: max_items). Nothing is created. |
| BATCH_EXCEEDS_BALANCE | 422 | The total of the new lines exceeds the available balance (details: batch_total, available). Nothing is created. |
In your dashboard
The Wallet page shows your balance, your movements and your payouts, filterable by status, customer reference and period, with your limits in force. The Export CSV button exports every line matching the filter (semicolon separator, UTF-8, times in UTC). No payout is created or retried from the dashboard: only through the API, with your key.
Testing in sandbox
Same API on https://api.elyonpay.net/api, with your sandbox account's key and no real money. Deposits can be paid out there immediately (release_delay_hours is 0): you can deposit, pay out, receive webhooks and try the API refusals (balance, limits, duplicates) in the same session.
Payment Statuses
Each payment goes through several statuses during its lifecycle.
State Transition Diagram
stateDiagram-v2
[*] --> CREATED
CREATED --> PENDING: Payment initiated
PENDING --> WAITING_FOR_PAYMENT: Awaiting customer
WAITING_FOR_PAYMENT --> ACCEPTED: Payment confirmed
PENDING --> ACCEPTED: Direct approval
PENDING --> REJECTED: Validation failed
PENDING --> DECLINED: Payment issue
WAITING_FOR_PAYMENT --> DECLINED: Payment issue
WAITING_FOR_PAYMENT --> CANCELLED: User cancellation
ACCEPTED --> DELIVERED: Funds transferred
ACCEPTED --> CANCELLED: Administrative action
REJECTED --> [*]
DECLINED --> [*]
CANCELLED --> [*]
DELIVERED --> [*]
Error Codes
In case of error, the API returns a JSON object with a machine error code and a readable message.
{
"success": false,
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The customer's Mobile Money balance is insufficient.",
"http_status": 402
}
}Error Table
| Error Code | HTTP | Description |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid API key |
| INVALID_AMOUNT | 400 | Invalid amount or below minimum allowed |
| CURRENCY_NOT_SUPPORTED | 400 | Currency not supported for this network |
| PHONE_INVALID | 400 | Invalid or unregistered phone number |
| INSUFFICIENT_FUNDS | 402 | Insufficient balance on client side |
| OPERATOR_TIMEOUT | 408 | Mobile Money operator did not respond in time |
| DUPLICATE_REQUEST | 409 | Duplicate request — use Idempotency-Key |
| PAYMENT_NOT_FOUND | 404 | Payment not found |
| PLAYER_REF_REMOVED | 422 | player_ref is no longer accepted: send customer.account_ref |
| PLAYER_NAME_REMOVED | 422 | player_name is no longer accepted: send customer.name |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests — retry in a few seconds |
| INTERNAL_ERROR | 500 | ElyonPay internal error — contact support |
Supported Currencies
| Code | Currency | Minimum Amount | Methods |
|---|---|---|---|
XAF | Franc CFA BEAC | 100 XAF | MTN MoMo, Orange Money, Carte |
XOF | Franc CFA BCEAO | 100 XOF | Wave, Moov, Orange Money, MTN, Carte |
EUR | Euro | 0.50 EUR | Carte (Visa, Mastercard), PayPal |
USD | US Dollar | 0.50 USD | Carte, PayPal |
GBP | British Pound | 0.30 GBP | Carte |
NGN | Nigerian Naira | 100 NGN | MTN MoMo NG |
KES | Kenyan Shilling | 10 KES | Airtel Money |
CDF | Congolese Franc | 500 CDF | Airtel Money |
