ElyonPay Logo

    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.

    Base URLs: 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:

    1. Contact us to get your API key
    2. Install the SDK for your language or use cURL directly
    3. Call POST /api/request-to-pay/payment/link with the amount and redirect URLs
    bash
    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.

    POST/api/login
    ParameterTypeRequiredDescription
    usernamestringRequiredMerchant account email
    passwordstringRequiredAccount password
    rolestringRequiredROLE_MERCHANT_ADMIN
    bash
    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

    json
    {
      "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...."
    }

    Usage in HTTP Header

    bash
    Authorization: Bearer <your_jwt_token>
    Security: Never expose your JWT token on the client side (browser JavaScript). Always make calls from your backend server. The token expires after a configurable delay — remember to renew it.

    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.

    bash
    Authorization: Bearer pk_live_...
    Limited scope: For your security, an API key can only reach the payment and transaction endpoints (Bankcard, Cash, Mobile Money, Request-to-Pay, wallet and payouts, plus transaction lookups). Any other endpoint returns a 403 error. Account and dashboard management require a JWT.

    Environments

    ElyonPay offers two distinct environments to allow you to develop and test safely before going to production.

    Base URLs

    EnvironmentBase URLDescription
    Sandboxhttps://api.elyonpay.net/apiNo real charges — simulated data
    Productionhttps://api.elyonpay.org/apiReal transactions
    Use the Sandbox environment for development and testing. Switch to the production URL only when you are ready to process real payments.

    Mobile Money Test Numbers

    NumberNetworkSimulated Behavior
    +237600000001MTN MoMoSuccessful payment
    +237600000002MTN MoMoInsufficient balance
    +237690000001Orange MoneySuccessful payment
    +237690000002Orange MoneySimulated timeout
    +221700000001WaveSuccessful 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.

    Access the dashboard at dashboard.elyonpay.org to view your transactions in real time.

    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.

    🇨🇲CameroonXAF
    PhoneEmailPassword
    +237683191284demo+23733@elyonpay.comCompteDemo1
    🇨🇮Ivory CoastXOF
    PhoneEmailPassword
    +2250758789285demo+225@elyonpay.comCompteDemo1
    🇫🇷FranceEUR
    PhoneEmailPassword
    +33895555555demo+33@elyonpay.comCompteDemo1

    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

    1. Complete KYB (Know Your Business) verification in your dashboard
    2. Switch the base URL from api.elyonpay.net to api.elyonpay.org
    3. Verify that your redirect URLs (success / error) use HTTPS
    4. Configure your low balance alerts
    ElyonPay uses a pull model: after the customer is redirected, your server must call GET /api/transactions/{uuid} to get the definitive status. Optionally, webhooks can additionally notify your server in real time.

    Request Format

    All requests and responses use JSON format. The Content-Type: application/json header is required for requests with a body.

    Required Headers

    HeaderValueRequired
    AuthorizationBearer {votre_token_jwt}Required
    Content-Typeapplication/jsonRequired (POST)
    Acceptapplication/jsonOptional
    Idempotency-KeyUUID uniqueRecommended

    Standard Response Format

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

    POST/api/request-to-pay/payment/link

    Body Parameters

    ParameterTypeRequiredDescription
    amountnumberRequiredPayment amount
    user_langstringRequiredPayment interface language (fr, en)
    msisdnstringRequiredPhone number in international format: +237600000000
    successstringRequiredRedirect URL on success
    errorstringRequiredRedirect URL on error
    webhookstringOptionalHTTPS URL notified on each transaction state change (see the Webhooks section)
    external_idstringOptionalYour order reference, 1 to 64 characters, unique per merchant. Returned by GET /api/transactions/{uuid} (externalId) and in webhooks (data.external_id)
    customerobjectOptionalYour 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_refstringOptionalThe 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.namestringOptionalDeclared name of the customer, 128 characters at most. Shown in your merchant area; never used as a key.
    customer.emailstringOptionalThe customer's e-mail, 190 characters at most, validated (422 otherwise). Kept on the transaction for messages addressed to them
    customer.phonestringOptionalThe customer's declared phone number, international format. Same as msisdn: either one is enough. At payment time the number actually charged replaces it
    customer.localestringOptionalLanguage of the messages addressed to the customer (fr, en, en-GB…)
    onbehalfofstring | numberOptionalCollect on behalf of a sub-merchant: its id or partner code. Platform merchants only (see the Platform Payments section)
    feesnumberOptionalPlatform commission to withhold (same unit as amount). If omitted, the sub-merchant then platform default fees apply
    Renamed fields: 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

    json
    {
      "url": "https://app.elyonpay.org/t/CI032667e43abf11?success=https://www.success.com&error=https://www.error.com"
    }

    Code Examples

    cURL

    bash
    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

    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 customer

    PHP

    php
    $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 link

    Transactions

    Retrieve the complete history of your transactions: received payments, refunds, fees.

    GET/api/transactions

    Returns a paginated list of all your transactions. You can also retrieve details of a specific transaction via its identifier.

    Transaction Detail

    GET/api/transactions/{id}
    bash
    curl -X GET https://api.elyonpay.org/api/transactions/1553 \
      -H "Authorization: Bearer <your_jwt_token>" \
      -H "Accept: application/json"

    Response Example

    json
    {
      "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

    FieldTypeDescription
    idintegerInternal transaction identifier
    PSPidstringUnique ElyonPay identifier (e.g. FR033169cbd9c1f1)
    uuidstringUnique identifier — same value as PSPid
    creation_datedatetimeCreation date (ISO 8601 with timezone)
    validation_datedatetime|nullPayment validation date — null if not yet validated
    delivery_datedatetime|nullDelivery date — null if not applicable
    amountnumberTransaction amount in the beneficiary's currency
    totalCustomernumberTotal amount charged to the customer (fees included)
    statestringCurrent status: CREATED, PENDING, WAITING_FOR_PAYMENT, ACCEPTED, DELIVERED, REJECTED, DECLINED, CANCELLED
    ticket_typestringNotification type: SMS_EMAIL, SMS, EMAIL
    operation_codestringInternal operation code
    payer_phone_numberstringPayer's phone number (international format)
    externalIdstring|nullYour order reference (external_id), as given at creation
    accountRefstring|nullYour end customer's identifier (customer.account_ref), as given at creation
    accountNamestring|nullDeclared name of the end customer (customer.name)
    customerEmailstring|nullDeclared e-mail of the end customer (customer.email)
    beneficiary_phone_numberstringBeneficiary's phone number
    payer_currencystringPayer's currency (ISO 4217 code: EUR, XAF, XOF...)
    beneficiary_currencystringBeneficiary's currency
    payer_countrystringPayer's country code (ISO 3166: FR, CM, CI...)
    beneficiary_countrystringBeneficiary's country code
    payment.namestringPayment provider used (MOLLIE, MTN, ORANGE...)

    customer Object

    FieldTypeDescription
    phoneNumberstringCustomer's phone number
    emailstringCustomer's email address

    merchant Object

    FieldTypeDescription
    idintegerMerchant identifier
    namestringMerchant or business name
    citystringMerchant's city
    addressstringPostal address
    zipCodestringZip code
    typestringAccount type: individual, company
    country_codestringISO 3166 country code (FR, CM, CI...)
    countrystringCountry name

    transactionStates Array

    History of transaction status changes, from oldest to newest.

    FieldTypeDescription
    statestringStatus at that point (CREATED, PENDING, WAITING_FOR_PAYMENT, ACCEPTED, DELIVERED...)
    datedatetimeStatus change date

    Fees

    FieldTypeDescription
    commission_operateurnumberTelecom operator commission
    commission_marchandnumberMerchant commission
    commission_paysnumberCountry commission
    commission_customernumberCommission applied to the customer
    deliveryPricenumberDelivery 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.

    When to use Custom Checkout? Use this approach if you need a custom UI or full control over the payment flow. For a simple fixed-amount payment, the payment link (Create Payment section) is faster to integrate.

    4-Step Flow

    1

    Fetch payment methods

    Call GET /api/public/configuration/payments/{countryCode} to get available methods in the customer's country.

    2

    Display your checkout

    Show the order summary and payment methods in your own UI. The customer picks how to pay.

    3

    Initiate payment

    Send the direct payment request (Mobile Money or bank card) with the chosen method.

    4

    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.

    GET/api/public/configuration/payments/{countryCode}

    Path Parameter

    ParameterTypeRequiredDescription
    countryCodestringRequiredISO 3166 country code — see table below

    Supported Country Codes

    CodeCountry
    CMCameroon
    CIIvory Coast
    GAGabon
    FRFrance
    TGTogo
    BJBenin
    Public endpoint: This endpoint requires no authentication. You can call it directly from the frontend to dynamically adapt your payment UI.

    Response Example

    json
    {
      "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
        }
      ]
    }
    bash
    curl https://api.elyonpay.org/api/public/configuration/payments/CM

    Direct 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

    POST/api/mobile-money/payment/request

    Body Parameters

    ParameterTypeRequiredDescription
    amountnumberRequiredPayment amount in local currency
    user_langstringRequiredUser language (fr, en)
    merchant_namestringRequiredMerchant name shown to the customer
    merchant_idintegerRequiredMerchant identifier
    currencystringRequiredISO 4217 currency code (XAF, XOF, EUR...)
    country_namestringRequiredCustomer's country name
    payment_methodstringRequiredMust match the name field returned by GET /api/public/configuration/payments/{countryCode}
    msisdnstringRequiredCustomer phone number (international format)
    transactionstringOptionalExternal transaction reference (optional)

    cURL

    bash
    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

    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 status

    PHP

    php
    $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 status

    Bank Card

    POST/api/bankcard/payment/request

    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

    ParameterTypeRequiredDescription
    payment_methodstringRequiredMOLLIE or STRIPE — the provider processing the card. These are the only two accepted values: anything else is rejected with a 422.
    successstringOptionalRedirect URL after a successful payment. The customer will be redirected to {success}?id={uuid}&status=pending
    errorstringOptionalRedirect 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

    json
    {
      "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

    FieldTypeDescription
    successbooleanWhether the request succeeded
    data.PSPidstringPayment Service Provider identifier
    data.PSPnotifyIdstringPSP notification identifier
    data.PSPtxnIdstringPSP transaction identifier
    data.urlstringPayment URL (card redirect if applicable)
    data.uuidstringUnique ElyonPay identifier — use this for polling
    data.PSPMessagestring|nullMessage returned by the PSP (null if none)
    data.PSPstatusstringStatus 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

    StateResultDescription
    DELIVEREDSuccessPayment confirmed, funds transferred
    FAILEDFailurePayment failed (insufficient balance, network error...)
    DECLINEDFailurePayment declined by the operator
    CANCELLEDFailureCancelled by the customer or system
    PENDINGPaidPayment confirmed. Despite its name, this state means the money has been collected: you can fulfil the order. What it awaits is delivery, not payment.
    Upcoming change: PENDING will be renamed WAITING_FOR_DELIVERY

    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.

    Polling interval: Poll every 3–5 seconds. Set a maximum timeout of about 2 minutes — beyond that, consider the transaction expired and display an appropriate message to the customer.

    Complete Example — Node.js / Express

    This example covers all 4 Custom Checkout steps: payment methods, display, direct payment and status polling.

    javascript
    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.

    Coming soon: These fields are not processed by the API yet. You can already send them without triggering an error, but they currently have no effect: nothing is stored or checked on our side yet. When this feature goes live, a missing field will first be flagged in the response — never rejected overnight. You'll have time to comply before a field becomes actually mandatory, and we'll announce the date in advance.

    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.

    EndpointRailPlanned status
    /stripe/payment/requestCardRequired
    /bankcard/payment/requestCardRequired
    /request-to-pay/payment/requestCard possibleRequired
    /request-to-pay/payment/initializeCard possibleRequired
    /request-to-pay/payment/linkCard, paid laterRequired
    /mobile-money/payment/request, /cash/payment/requestIrrevocableAccepted, 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.

    ParameterTypeRequiredDescription
    external_idstringRequiredYour own order reference. This is what lets you reconcile our transactions with yours — we cannot invent it for you.
    product_typestring (enum)RequiredWhat you're delivering: DIGITAL_DOWNLOAD, DIGITAL_ACCESS, SERVICE or PHYSICAL. Automatically determines the confirmation mode (see below) — you don't pick it separately.
    service_descriptionstringRequiredA short sentence describing what's sold. This is what the card network's dispute analyst will read.
    terms.accepted_atstring (ISO 8601)RequiredTimestamp of your terms of sale being accepted by the customer.
    terms.versionstringRequiredThe version of your terms that was accepted.
    customer.emailstringConditionalPayer's email. At least one of email or phone will be required; both can be provided.
    customer.phonestringConditionalPayer'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_refstringOptionalThe 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.namestringOptionalDeclared 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

    ValeurExampleConfirmation mode
    DIGITAL_DOWNLOADMovie, PDF, downloadable courseSigned link — the click is the confirmation
    DIGITAL_ACCESSSubscription, access opened in a third-party systemValidation by your own system
    SERVICEService performedQR code shown at delivery
    PHYSICALPhysical good handed over in personQR 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).

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

    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.

    1

    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.

    2

    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.

    3

    Payment validation

    The customer confirms the payment on the operator side (USSD code for Mobile Money, 3D Secure for cards). ElyonPay processes the transaction.

    4

    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.

    5

    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.

    6

    Display confirmation to the customer

    Based on the returned status (DELIVERED, REJECTED, CANCELLED...), display the appropriate confirmation or error page to your customer.

    Pull model: No webhook is mandatory: your backend controls the verification timing. To be notified in real time, you can additionally provide a webhook URL — see the Webhooks section.

    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

    javascript
    // 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 });
      }
    });
    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}.

    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 CodeNetworkCountriesCurrencies
    mtn_mobile_moneyMTN MoMoCM, NG, CI, GH, UGXAF, NGN, XOF
    orange_moneyOrange MoneyCM, CI, SN, MLXAF, XOF
    waveWaveSN, CI, BF, MLXOF
    moov_moneyMoov AfricaBJ, TG, BF, NEXOF
    airtel_moneyAirtel MoneyKE, TZ, UG, ZM, CDKES, 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 NumberExpiryCVVBehavior
    4242 4242 4242 424212/28123Successful payment
    4000 0000 0000 000212/28123Card declined
    4000 0000 0000 322012/281233D Secure required
    5555 5555 5555 444412/28123Mastercard 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.

    Feature subject to activation and contract: Split payments are not enabled by default. This feature must be activated by our sales team before it appears in your interface, and access is conditional on the merchant explicitly signing a partner contract. Until the contract is signed and platform mode is enabled on your account, the endpoints and screens described below remain unavailable. Contact our sales team to get started.

    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.
    POST/api/merchant/sub-merchants

    Body Parameters

    ParameterTypeRequiredDescription
    namestringRequiredSub-merchant business name
    country_codestringRequiredISO 3166 country code (e.g. CM)
    citystringRequiredCity
    addressstringRequiredAddress
    typestringRequiredBusiness type (e.g. RETAIL)
    owner_emailstringRequiredAccount owner's email
    owner_phone_numberstringRequiredOwner's phone number
    owner_first_namestringRequiredOwner's first name
    owner_last_namestringRequiredOwner's last name
    owner_passwordstringRequiredInitial password for the owner account
    default_marketplace_fee_percentnumberOptionalDefault commission (%) withheld on this sub-merchant's payments. Otherwise the platform default fees apply
    bash
    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
      }'
    json
    {
      "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

    MethodEndpointDescription
    GET/api/merchant/sub-merchantsList your sub-merchants
    GET/api/merchant/sub-merchants/{id}Get a sub-merchant
    PATCH/api/merchant/sub-merchants/{id}/feesUpdate 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.

    bash
    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:

    1. The fees parameter passed in the call (exact amount, takes priority when present).
    2. Otherwise, the sub-merchant's default commission (default_marketplace_fee_percent).
    3. Otherwise, the default commission configured on the platform.
    4. 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.

    Example: for amount = 15,000 XAF and a 5% commission, the sub-merchant receives 14,250 XAF and the platform 750 XAF. The breakdown is available in the transaction detail and in the webhook (data.split).

    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.

    bash
    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_modeamountWhen to use it
    SUBTRACTIVEdefaultWhat 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.
    ADDITIVEWhat 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.

    bash
    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

    SituationCode
    The calling account is not a platform, or the sub-merchant belongs to another platform403
    Sub-merchant not found (id or partner code)404
    Invalid commission (negative, ≥ amount, cap exceeded) or no payout account configured400
    Empty splits, percent ≤ 0, or a share rounding down to zero400
    The same beneficiary appears twice in splits400
    The shares leave nothing to the platform, which carries the pay-in cost400
    ADDITIVE mode without principal, or without a share for the platform itself400
    A splits beneficiary does not belong to the calling platform403

    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 payout mode is a commercial condition, set by ElyonPay on your account. It is not an API parameter: you cannot choose it per request, and it does not appear in API responses. To change it, talk to your account manager.

    The four payout modes

    ModeWhen the funds leaveWhat it means for you
    INSTANTdefaultAs 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.
    DELAYEDOn 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.
    MANUALWhen an ElyonPay operator releases the payout.For accounts whose payouts are reviewed before leaving. Standard fee tier.
    WALLETNever 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.

    bash
    curl -X GET https://api.elyonpay.org/api/wallet/balance \
      -H "Authorization: Bearer <your_jwt_token>"
    json
    {
      "currency": "XAF",
      "balance": 1250000,
      "pending_release": 300000,
      "available": 950000,
      "release_delay_hours": 72,
      "as_of": "2026-10-20T17:12:04+00:00"
    }
    FieldMeaning
    currencyThe balance currency, that of your account's country.
    balanceEverything we hold for you, available or not.
    pending_releaseThe part of that balance from deposits too recent to be paid out.
    availableWhat you can pay out right now: balance − pending_release.
    release_delay_hoursThe availability delay applied to your account, in hours.
    as_ofWhen 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.

    bash
    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:

    EventStateDescription
    transaction.pendingPENDINGPayment initiated, awaiting confirmation
    transaction.deliveredDELIVEREDPayment confirmed, funds transferred to the merchant
    transaction.declinedDECLINEDPayment declined by the operator
    transaction.cancelledCANCELLEDPayment cancelled by the customer or the system

    Notification Format

    Each notification is a JSON POST request, sent with the following headers:

    HeaderDescription
    Content-TypeAlways application/json
    X-Mip-EventEvent name (e.g. transaction.delivered)
    X-Mip-DeliveryUnique notification identifier — use it for deduplication
    X-Mip-AttemptAttempt number (1 to 5)

    Payload Example

    json
    {
      "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:

    AttemptDelay after previous failure
    1Immediate (on state change)
    23 seconds
    330 seconds
    460 seconds
    590 seconds

    After 5 unsuccessful attempts, the notification is permanently abandoned. The transaction status always remains available via GET /api/transactions/{uuid}.

    Mandatory verification: A webhook can arrive late, duplicated, or out of order. Before delivering goods or services, always confirm the transaction state with GET /api/transactions/{uuid} — never rely solely on the webhook content.

    Receiver Example (Node.js / Express)

    javascript
    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.

    Feature subject to activation and contract: wallet mode is not enabled by default. Our team enables it on your account once a contract is signed, which also sets your payout limits. Contact us to open it.

    How it works

    1. Deposit. Your customer pays as for any payment (see Create a payment), with customer.account_ref if you want to find their deposits again. Once the operator confirms the payment, its amount net of fees is credited to your balance.
    2. 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).
    3. 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.
    4. 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.

    Keep your customers' numbers on your side: take the number to pay from your customer's account, authenticated on your side, never from an ElyonPay response or webhook. A deposit made by someone else under a customer's 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.

    bash
    Authorization: Bearer pk_live_...
    Good practice: your key is in your dashboard, under Developer. Keep it on your server, never in a browser or a mobile app. If it may have leaked, regenerate it: the old one stops working at once. ElyonPay executes every valid request made with your key: approve your requests on your side (four-eyes check, confirmation) before calling the API.

    Endpoints

    MethodPathPurpose
    GET/api/wallet/balanceBalance, pending, available
    POST/api/wallet/payoutsCreate a payoutAPI key
    POST/api/wallet/payouts/batchSend a batch of payoutsAPI key
    GET/api/wallet/payoutsJournal, filtered and paginated
    GET/api/wallet/payouts/{id|reference}One payout
    POST/api/wallet/payouts/{id|reference}/retryRetry a FAILED payoutAPI key

    Reading the balance

    GET/api/wallet/balance
    json
    {
      "currency": "XAF",
      "balance": 1250000,
      "pending_release": 300000,
      "available": 950000,
      "release_delay_hours": 72,
      "as_of": "2026-11-02T17:12:04+00:00"
    }
    FieldDescription
    balanceWhat we hold for you.
    pending_releaseDeposits credited less than release_delay_hours ago, not yet payable.
    availableWhat you can pay out now: balance − pending_release.
    release_delay_hoursDelay before a deposit becomes payable.
    as_ofTime 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

    POST/api/wallet/payouts
    ParameterTypeRequiredDescription
    referencestringRequiredYour 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_typestringOptionalCUSTOMER (default) for a customer, MERCHANT for your margin, paid to the payout account registered with ElyonPay.
    msisdnstringConditionalRequired 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_refstringOptionalYour identifier for the customer, at most 64 characters: a label for your journal and filters, never what decides where the money goes.
    beneficiary_namestringOptionalBeneficiary name, at most 128 characters. Otherwise, the name declared with the deposit from that number.
    amountintegerRequiredAmount, strictly positive integer, in the currency unit.
    descriptionstringOptionalAt most 255 characters. Depending on the operator, shown in the SMS the customer receives.
    allow_duplicatebooleanOptionaltrue to confirm a detected duplicate (409 DUPLICATE_SUSPECTED).
    webhookstringOptionalhttps URL, at most 500 characters, that will receive the payout's outcome (see below).
    bash
    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).

    json
    {
      "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
    }
    FieldDescription
    idElyonPay identifier of the payout.
    msisdn, payment_methodThe number paid, digits only with the country code, and its operator (ORANGE_MONEY or MTN_MONEY), taken from its deposit.
    account_refYour customer label, if you gave one.
    beneficiary_nameThe name in the request, otherwise the one declared at deposit (customer.name).
    statusSee Lifecycle below.
    attemptAttempt number: 1, then +1 on each retry.
    failure_reasonWhy a payout failed or is under review.
    operator_referenceTransfer reference at the operator, once sent.
    completed_atTime of the final outcome (SUCCEEDED or FAILED).

    Sending a batch

    POST/api/wallet/payouts/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.

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

    json
    {
      "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

    GET/api/wallet/payouts
    ParameterTypeRequiredDescription
    statusstringOptionalQUEUED, PROCESSING, SUCCEEDED, FAILED or ON_HOLD.
    account_refstringOptionalThe payouts carrying this customer label.
    batch_refstringOptionalThe lines of a batch.
    from, toISO 8601OptionalCreation period; to is excluded.
    limit, offsetintegerOptionalPagination: limit from 1 to 200 (50 by default).
    bash
    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

    POST/api/wallet/payouts/{id|reference}/retry

    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

    StatusMeaningBalance
    QUEUEDAccepted, waiting to be sent to the operator.Reserved
    PROCESSINGSent to the operator, waiting for its answer.Reserved
    SUCCEEDEDPaid. Final status.Paid out
    FAILEDRefused by the operator. Final status, unless retried.Returned
    ON_HOLDUnder 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
    Why money stays reserved without an answer: an outage or a timeout never counts as a refusal. The amount only returns to your balance on a certain refusal from the operator; otherwise it stays reserved until there is an answer. This is what prevents paying the same amount twice.

    Payout webhooks

    With a webhook in the request, ElyonPay sends a JSON POST on each outcome. Nothing is sent for QUEUED or PROCESSING.

    EventWhenBalance
    payout.succeededThe operator paid, or our review confirmed it.The money is out
    payout.failedThe operator refused, or our review found nothing was sent.Amount returned, retry possible
    payout.on_holdThe 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).

    json
    {
      "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"
      }
    }
    HeaderDescription
    X-Mip-EventThe event.
    X-Mip-DeliveryNotification identifier, the same across attempts: deduplicate on it.
    X-Mip-AttemptAttempt 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.

    javascript
    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):

    CheckRefusal
    Amount at most the per-payout limitAMOUNT_ABOVE_LIMIT
    Number given, that has paid you, with Orange Money or MTN MoMoMSISDN_REQUIRED, MSISDN_NOT_ELIGIBLE, RAIL_NOT_SUPPORTED
    Amount at most the available balanceINSUFFICIENT_FUNDS
    Day's total at most the wallet daily limitDAILY_LIMIT_EXCEEDED
    Day's total to this number at most the daily limit per numberDESTINATION_DAILY_LIMIT_EXCEEDED
    Not the same amount to the same number within the duplicate windowDUPLICATE_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.

    json
    {
      "error_code": "INSUFFICIENT_FUNDS",
      "message": "The available wallet balance does not cover this payout.",
      "available": 950000,
      "pending_release": 300000
    }
    CodeHTTPDescription
    API_KEY_REQUIRED403Creating, batching or retrying without the merchant API key (dashboard session, JWT).
    OWNER_ONLY403Payouts read by a user other than the account holder (a cashier, for instance).
    MERCHANT_BLOCKED403Your account is under review: payouts are suspended. Contact us.
    NOT_A_WALLET404The account is not in wallet mode.
    NOT_FOUND404No payout under this id or reference.
    INVALID_JSON400The body is not a JSON object (or, for a batch, has no items list).
    STATUS_INVALID, FROM_INVALID, TO_INVALID400Unreadable journal filter.
    REFERENCE_REQUIRED, REFERENCE_INVALID422reference missing, empty or longer than 64 characters.
    AMOUNT_INVALID422amount is not a strictly positive integer.
    AMOUNT_ABOVE_LIMIT422Amount above the per-payout limit (detail: max_amount).
    BENEFICIARY_TYPE_INVALID422beneficiary_type other than CUSTOMER or MERCHANT.
    MSISDN_REQUIRED422No msisdn for a payout to a customer.
    ACCOUNT_REF_INVALID, BENEFICIARY_NAME_INVALID422account_ref or beneficiary_name too long, or with a control character.
    DESCRIPTION_INVALID, BATCH_REF_INVALID, WEBHOOK_INVALID422Field too long, control character, or a webhook that is not an https URL.
    MSISDN_NOT_ELIGIBLE422This number has never paid you: no confirmed Mobile Money deposit from it (detail: msisdn).
    RAIL_NOT_SUPPORTED422The account to pay is neither Orange Money nor MTN MoMo.
    MERCHANT_ACCOUNT_MISSING422Margin payout without a registered payout account.
    INSUFFICIENT_FUNDS422The available balance does not cover the amount (details: available, pending_release).
    DAILY_LIMIT_EXCEEDED422Wallet daily limit reached (details: daily_limit, used_today).
    DESTINATION_DAILY_LIMIT_EXCEEDED422Daily limit for this number reached (details: destination_daily_limit, used_today, msisdn).
    DUPLICATE_SUSPECTED409Same amount to the same number within the duplicate window (details: duplicate_of, duplicate_created_at). Resend with allow_duplicate: true if intended.
    REFERENCE_REUSED409This reference already holds a different payout (detail: payout).
    REFERENCE_DUPLICATED_IN_BATCH409The same reference appears twice in the batch.
    NOT_RETRYABLE409Only a FAILED payout can be retried (detail: status).
    BATCH_EMPTY, INVALID_ITEM422Batch without lines, or a line that is not an object.
    BATCH_TOO_LARGE422Too many lines in the batch (detail: max_items). Nothing is created.
    BATCH_EXCEEDS_BALANCE422The 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.

    CREATED
    Transaction created
    PENDING
    Payment initiated, pending
    WAITING_FOR_PAYMENT
    Awaiting customer payment
    ACCEPTED
    Payment confirmed
    DELIVERED
    Funds transferred to merchant
    REJECTED
    Validation failed
    DECLINED
    Payment declined
    CANCELLED
    Cancelled by user or admin

    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.

    json
    {
      "success": false,
      "error": {
        "code": "INSUFFICIENT_FUNDS",
        "message": "The customer's Mobile Money balance is insufficient.",
        "http_status": 402
      }
    }

    Error Table

    Error CodeHTTPDescription
    UNAUTHORIZED401Missing or invalid API key
    INVALID_AMOUNT400Invalid amount or below minimum allowed
    CURRENCY_NOT_SUPPORTED400Currency not supported for this network
    PHONE_INVALID400Invalid or unregistered phone number
    INSUFFICIENT_FUNDS402Insufficient balance on client side
    OPERATOR_TIMEOUT408Mobile Money operator did not respond in time
    DUPLICATE_REQUEST409Duplicate request — use Idempotency-Key
    PAYMENT_NOT_FOUND404Payment not found
    PLAYER_REF_REMOVED422player_ref is no longer accepted: send customer.account_ref
    PLAYER_NAME_REMOVED422player_name is no longer accepted: send customer.name
    RATE_LIMIT_EXCEEDED429Too many requests — retry in a few seconds
    INTERNAL_ERROR500ElyonPay internal error — contact support

    Supported Currencies

    CodeCurrencyMinimum AmountMethods
    XAFFranc CFA BEAC100 XAFMTN MoMo, Orange Money, Carte
    XOFFranc CFA BCEAO100 XOFWave, Moov, Orange Money, MTN, Carte
    EUREuro0.50 EURCarte (Visa, Mastercard), PayPal
    USDUS Dollar0.50 USDCarte, PayPal
    GBPBritish Pound0.30 GBPCarte
    NGNNigerian Naira100 NGNMTN MoMo NG
    KESKenyan Shilling10 KESAirtel Money
    CDFCongolese Franc500 CDFAirtel Money