Aller au contenu
Developer Documentation

Build with EasyPay

Integrate mobile money payments into your websites, mobile apps, and business systems using our REST API, JavaScript SDK, and pre-built plugins.

Base URL: https://easypay.emko.inkCurrency: MRUv2.0

Getting Started

EasyPay is the leading payment platform for Mauritania. Accept mobile money payments (Bankily, Sedad, Masrvi, BFI Cash), QR code payments, NFC tap-to-pay, and web checkout -- all through a single, unified API.

This documentation covers everything you need to integrate EasyPay into your application, from creating your first checkout session to handling webhooks in production.

Payment Methods

Mobile Money

Bankily, Sedad, Masrvi, BFI Cash wallets

QR Code Payments

Static and dynamic QR codes for in-store

NFC Tap-to-Pay

Contactless payments via NFC-enabled devices

Web Checkout

Hosted checkout page or JavaScript SDK popup

Environments

EnvironmentBase URLKey Prefix
Sandboxhttps://easypay.emko.inkek_test_...
Productionhttps://easypay.emko.inkek_live_...

Both environments share the same base URL: the API key decides. A checkout session created with an ek_test_ key is a sandbox session — see Testing & Sandbox.

Content Type

application/json

Currency

MRU (Mauritanian Ouguiya)
Quick start: Sign up for a merchant account, grab your test API key from the dashboard under Settings → API & Integration, and start making API calls against the sandbox in minutes.

Authentication

EasyPay uses two credentials, depending on the endpoint: an API key for checkout sessions, and the session of your EasyPay account for the other merchant endpoints.

CredentialUsed by
X-API-Key: ek_live_… / ek_test_…Checkout sessions: create, list, cancel. No other endpoint accepts the API key.
Authorization: Bearer <accessToken>Your merchant session, from POST /api/v1/auth/login: QR codes, payment links, transactions, account webhooks.
None (public)Reading a checkout session by its token, a QR code by its id or a payment link by its slug, and the fee endpoints.

API Keys

Generate your keys in the EasyPay app — merchant account or Pro space — under Settings → API & Integration. The live key and the test key are generated together and shown once; regenerating replaces both, and the previous keys stop working at once.

Key TypePrefixUsage
Live Keyek_live_Real payments.
Test Keyek_test_Sandbox checkout sessions, on the same base URL: no money moves (see Testing & Sandbox).

Example: list your checkout sessions

bash
curl https://easypay.emko.ink/api/v1/checkout/sessions \
  -H "X-API-Key: ek_test_abc123def456"

A missing X-API-Key header gives 400, an unknown key 401 with "message": "Invalid API key".

Both keys are secret — there is no publishable key. Use them only from your server: the key creates, lists and cancels your checkout sessions, and it signs your checkout.paid callbacks, so anyone who reads it can forge them. A browser never needs it: send the customer to the checkoutUrl of a session your server created (see JavaScript SDK). The browser SDK takes no key either: EasyPay.open() only opens a checkoutUrl created by your server.

Merchant Session

The other merchant endpoints take the session of your EasyPay account, as the EasyPay app does: sign in with the account's phone number and PIN, then send the access token in the Authorization header.

ParameterTypeRequiredDescription
phonestringRequiredPhone number of the account, international format: +222…
pinstringRequiredPIN of the account (4 digits).
deviceIdstringOptionalIdentifies your client as one device — up to 64 letters, digits, dots, dashes, underscores or colons. Send the same value at every login.
deviceNamestringOptionalReadable name of that device, up to 80 characters.
bash
# 1. Sign in with the phone number and PIN of your EasyPay account
curl -X POST https://easypay.emko.ink/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+22236000000",
    "pin": "1234",
    "deviceId": "order-server-1",
    "deviceName": "Order server"
  }'

# 2. Send data.tokens.accessToken with your requests
curl https://easypay.emko.ink/api/v1/transactions \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
TokenLifetime and use
accessTokenValid 15 minutes (expiresIn: 900, in seconds). Send it as Authorization: Bearer …; once expired, the API answers 401.
refreshTokenValid 7 days, single use: POST /api/v1/auth/refresh with { "refreshToken": "…" } returns a new pair. Reusing a refresh token that was already exchanged closes every session of the account (401).
Sign outPOST /api/v1/auth/logout with { "refreshToken": "…" } ends the session and frees the device.

A wrong phone number or PIN gives 401 with "message": "Invalid credentials". Logins are limited to 10 per minute per IP address (429 beyond).

One device at a time. A merchant account is open on one device at a time, and your script is a device. While the EasyPay app is in use on a phone — any activity in the last 3 minutes — the login is refused with 409 SESSION_ACTIVE_ON_ANOTHER_DEVICE and a Retry-After header; otherwise it closes the app's session, which is signed out. Send the same deviceId at every login and sign out when you are done. To collect payments from a server, prefer checkout sessions: the API key does not depend on any device.

JavaScript SDK

The customer always pays on the EasyPay payment page. Your server creates a checkout session with its API key; the browser only opens the session's checkoutUrl. The script https://easypay.emko.ink/sdk/easypay.js (version 2.0.0) does that part for you: it opens the page in a popup and gives your page the result. It defines a global EasyPay object. There is no npm package: @easypay/js is not published.

The API key never goes in a web page. It creates, lists and cancels your checkout sessions, and it signs your checkout.paid callbacks: anyone who reads the page could act as you. The SDK takes no key. EasyPay.init() throws if it receives one, and a button whose element carries a key is not rendered.

Open the Payment Page

Your server creates the session (see Checkout Sessions) and returns its checkoutUrl. In the browser, call EasyPay.open() inside the click handler: browsers block a popup opened after an await. When the URL comes from a request to your server made at click time, pass a function or a promise: the popup opens at once and loads the payment page as soon as the URL is known.

javascript
// Your server creates the session, with your API key (never sent to the browser)
app.post("/orders/:id/pay", async (req, res) => {
  const order = await findOrder(req.params.id); // your own lookup, not an amount from the browser
  const response = await fetch("https://easypay.emko.ink/api/v1/checkout/sessions", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.EASYPAY_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      title: "Order #" + order.id,
      amount: order.total,
      orderId: String(order.id),
      returnUrl: "https://yoursite.com/orders/" + order.id + "/return",
      callbackUrl: "https://yoursite.com/easypay/callback",
    }),
  });
  const body = await response.json();
  if (!response.ok) return res.status(502).json({ error: body.detail || body.message });

  await saveSessionToken(order.id, body.data.sessionToken);
  // Only the payment page URL goes to the browser
  res.json({ checkoutUrl: body.data.checkoutUrl });
});

If the page already has the URL (your server created the session when it rendered the payment step), pass it directly:

javascript
payButton.addEventListener("click", () => {
  EasyPay.open(checkoutUrl).then(onResult, onError);
});
ArgumentDescription
targetThe session's checkoutUrl, its bare sessionToken, or a promise or function that gives one. Only https://easypay.emko.ink/checkout/{sessionToken} is opened: any other URL rejects the promise.
options.mode"popup" (default) or "redirect".
options.popupFallbackWhen the browser blocks the popup: "redirect" (default) opens the payment page in the current tab, "reject" rejects with "Popup blocked…".

In popup mode, EasyPay.open() resolves with { sessionToken, status, transactionId }. It only accepts the message of the window it opened, from the EasyPay origin, for that session. If your function or promise fails, the popup closes and the promise rejects with that error.

statusMeaning
paidThe payment page reports the payment as successful.
failedThe payment was rejected.
cancelledThe customer closed the popup, or used « Retour à la boutique » (shown when the session has a cancelUrl). The session stays OPEN until it expires.
expiredThe customer left an expired session with « Retour à la boutique ».
  • The result only drives your page: confirm the order on your server, from the checkout.paid callback or by reading the session (PAID) — see Webhooks.
  • A customer without an EasyPay account pays after an SMS code, and the payment waits for your approval in the EasyPay app: the page reports its result only when that payment is approved or rejected. If the customer closes the popup earlier, you get cancelled: rely on checkout.paid.
  • The payment page cannot be shown inside an iframe: it is served with X-Frame-Options: DENY. Use a popup or a redirect.

Redirect

EasyPay.open(checkoutUrl, { mode: "redirect" }) sends the current tab to the payment page (or set it for the whole page with EasyPay.init({ mode: "redirect" })). You can also do without the script: answer the customer's "Pay" click on your server with a redirect to checkoutUrl. Once the payment is final, the page sends the customer back to your returnUrl with easypay_status=paid or failed, session and transactionId: a hint as well, to check on your server.

Buttons

EasyPay.renderButton(selector, options) renders a "Payer avec EasyPay" button that calls EasyPay.open(). The URL comes from options.getCheckoutUrl() (called at each click), options.checkoutUrl or the element's data-checkout-url. Elements with a data-easypay-button attribute become buttons on their own when the page loads.

html
<script src="https://easypay.emko.ink/sdk/easypay.js"></script>

<!-- Without JavaScript: your server writes the checkoutUrl of the session -->
<div data-easypay-button data-checkout-url="<%= checkoutUrl %>"></div>

<!-- With callbacks: the URL is asked from your server at each click -->
<div id="easypay-button"></div>
<script>
  EasyPay.renderButton("#easypay-button", {
    getCheckoutUrl: () =>
      fetch("/orders/1234/pay", { method: "POST" })
        .then((response) => response.json())
        .then((data) => data.checkoutUrl),
    onSuccess: () => { window.location.href = "/orders/1234/return"; },
    onCancel: () => console.log("Payment page closed"),
    onFailure: () => showError("Payment rejected"),
    onError: (error) => showError(error.message),
  });
</script>

Reference

MethodWhat it does
EasyPay.open(target, options)Opens the payment page of a session created by your server and resolves with its result (popup mode).
EasyPay.renderButton(selector, options)Renders a payment button. Options: checkoutUrl, getCheckoutUrl, label, mode, popupFallback, onSuccess, onCancel, onFailure, onError.
EasyPay.init({ baseUrl, mode })Optional: the origin of the payment pages (default https://easypay.emko.ink, https only) and the default mode. It takes no key.
EasyPay.version"2.0.0"
EasyPay.checkout(), EasyPay.createSession()Removed in 2.0: they always reject with a message that explains the server step.

Migrating from 1.x

Version 1.x took your API key in the page and created the session from the browser. Every visitor could read the key, and the API refused those calls from any other website (CORS) anyway. Move the session creation to your server with the same fields, and open the returned checkoutUrl with EasyPay.open(). For the sandbox, create the session with your ek_test_ key: nothing changes in the browser.

If one of your pages ever contained your API key, regenerate your keys in the EasyPay app (Settings → API & Integration): the previous keys stop working at once. Then update the key on your server and in your plugin settings.

REST API Reference

The EasyPay REST API uses standard HTTP verbs, takes and returns JSON, and wraps successful responses in { success, message, data }. Authentication depends on the endpoint:

AuthenticationUsed by
X-API-Key: ek_live_… / ek_test_…Checkout sessions: create, list, cancel. No other endpoint accepts the API key.
Authorization: Bearer <accessToken>Your merchant session, from POST /api/v1/auth/login: QR codes, payment links, transactions, account webhooks.
None (public)Reading a checkout session by its token, a QR code by its id or a payment link by its slug, and the fee endpoints.

Checkout Sessions

POST/api/v1/checkout/sessions
GET/api/v1/checkout/sessions/{sessionToken}
GET/api/v1/checkout/sessions
POST/api/v1/checkout/sessions/{sessionToken}/cancel

QR Codes

POST/api/v1/qr-codes
GET/api/v1/qr-codes
GET/api/v1/qr-codes/{id}
GET/api/v1/qr-codes/{id}/image
PUT/api/v1/qr-codes/{id}
DELETE/api/v1/qr-codes/{id}

Payment Links

POST/api/v1/payment-links
GET/api/v1/payment-links
PUT/api/v1/payment-links/{id}
DELETE/api/v1/payment-links/{id}
GET/api/v1/payment-links/slug/{slug}

Transactions

GET/api/v1/transactions

Fees

GET/api/v1/fees/quote
GET/api/v1/fees/policies
Fees model: EasyPay charges only its own commission, with a policy per payment method: a percentage (with an optional floor and ceiling) or a fixed fee per amount tranche, paid either by the customer (added on top of the amount — you receive the full amount) or by the merchant (deducted from what you receive). Defaults: Checkout 2% (min 30, max 400 MRU) paid by the customer; recurring billing 2% and bank cards 2.9% deducted from the merchant; QR code, NFC and payment links free in 2026, then a tranche grid from January 2027. Policies can change: read them live rather than hard-coding a rate. Wallet-side fees (TOF 20%, TTE 0.1% on amounts ≥ 5000 MRU, Gimtel interchange) are always paid by the customer, collected by the source wallet (Bankily/Sedad/…) and simply reported by EasyPay for transparent display. When the wallet does not expose a quote API, the response sets walletQuoteAvailable: false and a disclaimer key. Payment webhooks (payment.*) and the checkout callback (checkout.paid) carry fee (commission deducted from you, 0 when the customer paid it), netAmount (what you receive) and feeBearer (BENEFICIARY or PAYER).

Webhooks

POST/api/v1/webhooks
GET/api/v1/webhooks
PUT/api/v1/webhooks/{id}
DELETE/api/v1/webhooks/{id}

These are authenticated with your merchant session (Authorization: Bearer), not with the API key. The checkout callback is configured per session with callbackUrl — see Webhooks.

Pagination: the lists of checkout sessions, payment links and transactions take page (from 0, default 0) and size (default 20, max 2000), newest first. The page is in data: content, totalElements, totalPages, number, size.

Checkout Sessions

A checkout session asks one customer to pay you a fixed amount. Create it from your server with your API key, send the customer to its checkoutUrl — the EasyPay payment page — and fulfil the order when you receive the signed checkout.paid callback. A session can be paid until it expires, 30 minutes after its creation.

Checkout Flow

1. Create the session (your server)→2. Send the customer to checkoutUrl→3. Customer pays→4. checkout.paid callback
EndpointAuthentication
POST /api/v1/checkout/sessionsX-API-Key — create a session
GET /api/v1/checkout/sessions/{sessionToken}None (public) — read a session
GET /api/v1/checkout/sessionsX-API-Key — list your sessions
POST /api/v1/checkout/sessions/{sessionToken}/cancelX-API-Key — cancel an open session
Keep the API key on your server. It creates, lists and cancels your sessions, and it is also the key that signs your checkout.paid callbacks: anyone who reads it can forge them.

Create a Session

POST/api/v1/checkout/sessions

Request Headers

ParameterTypeRequiredDescription
X-API-KeystringRequiredYour API key: ek_live_… for real payments, ek_test_… for sandbox sessions. Missing → 400, unknown → 401.
Content-TypestringRequiredapplication/json

Session Parameters

ParameterTypeRequiredDescription
titlestringRequiredShown to the customer on the payment page. Up to 255 characters.
amountnumberRequiredAmount in MRU, greater than 0, at most 2 decimals. With a live key it must be between 100 and 500 000 MRU (EasyPay's current limits), otherwise 400. EasyPay's commission is added on top when the customer pays it — see Fees in the REST API section.
returnUrlstringRequiredWhere the payment page sends the customer once the payment is final, with easypay_status, session and transactionId added to the query string. Up to 2048 characters.
currencystringOptionalMRU — the default and the only currency supported.
descriptionstringOptionalShown on the payment page under the title.
orderIdstringOptionalYour order reference, sent back in the checkout.paid callback. Up to 255 characters.
metadatastring | objectOptionalFree data for your own use, kept as a string: send a string (JSON recommended) or a JSON object, stored as its compact JSON string ({} becomes ""). Returned only in the checkout.paid callback.
callbackUrlstringOptionalServer-to-server URL that receives the signed checkout.paid callback (see Webhooks). Up to 2048 characters.
cancelUrlstringOptionalWhere the « Retour à la boutique » link of the payment page takes a customer who leaves without paying, with easypay_status=cancelled and session added to the query string — see The Payment Page. An absolute http or https URL, otherwise no link is shown. Up to 2048 characters.

Unknown fields are ignored. The expiry time is set by EasyPay — 30 minutes — and cannot be chosen per session.

bash
curl -X POST https://easypay.emko.ink/api/v1/checkout/sessions \
  -H "X-API-Key: ek_test_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Order #1234",
    "amount": 5000,
    "description": "2 x T-shirt",
    "orderId": "ORDER-1234",
    "returnUrl": "https://yoursite.com/orders/1234/return",
    "cancelUrl": "https://yoursite.com/cart",
    "callbackUrl": "https://yoursite.com/easypay/callback",
    "metadata": { "cart_id": "abc123" }
  }'

Response (201 Created)

json
{
  "success": true,
  "message": "Success",
  "data": {
    "id": "c6ef4a5f-1d0b-49a8-a1bb-a439897c13a4",
    "sessionToken": "d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2",
    "checkoutUrl": "https://easypay.emko.ink/checkout/d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2",
    "orderId": "ORDER-1234",
    "title": "Order #1234",
    "amount": 5000,
    "currency": "MRU",
    "description": "2 x T-shirt",
    "status": "OPEN",
    "returnUrl": "https://yoursite.com/orders/1234/return",
    "cancelUrl": "https://yoursite.com/cart",
    "merchantName": "My Shop",
    "userId": "2c0491ac-3769-43f5-95dd-071dedbb1930",
    "expiresAt": "2026-10-12T14:30:00.750849",
    "sandbox": true,
    "createdAt": "2026-10-12T14:00:00.750849"
  }
}
FieldDescription
sessionToken64 hexadecimal characters. Identifies the session in its URL and in the read and cancel endpoints; anyone who has it can read the session. Store it with your order.
checkoutUrlThe EasyPay payment page of the session, {base URL}/checkout/{sessionToken}. Send the customer there.
idSession UUID. The other endpoints take the sessionToken, not this id.
statusOPEN at creation — see the lifecycle below.
expiresAtcreatedAt plus 30 minutes. Server local time, ISO-8601 without timezone.
sandboxtrue for a session created with an ek_test_ key.
createdAtWhen the session was created, in the same format. Reading or listing the session returns the same value.
merchantName, userIdYour business name as the customer sees it, and your EasyPay account id.
Other fieldsAs you sent them. amount is read back with two decimals (5000.00). metadata and callbackUrl are not returned.

Errors

StatusWhen
400title, amount or returnUrl missing or blank, or amount not above 0 — "detail": "Invalid request content." (the response does not name the field).
400Body that is not valid JSON, or a value of the wrong type — "detail": "Failed to read request". Without Content-Type: application/json: 415.
400No X-API-Key header — "detail": "Required header 'X-API-Key' is not present."
400Live key and an amount outside the limits — "message": "Le montant minimum est de 100 MRU" (or maximum).
401Unknown API key — { "success": false, "message": "Invalid API key", "data": null }

The Payment Page

Open checkoutUrl in the customer's browser — redirect, link or popup window. For a popup, the JavaScript SDK does it for you: EasyPay.open(checkoutUrl) opens the page and gives your page the result (see JavaScript SDK). Only the URL goes to the browser: the API key never reaches it.

The page shows your business name, the title, the description and the amount; the customer chooses a wallet and pays:

  • from the EasyPay app — the payment is confirmed at once;
  • without an EasyPay account — the customer confirms their phone number with an SMS code, and the payment stays pending until you approve it in your EasyPay merchant app (or the wallet confirms it). It can be approved after expiresAt: the session then becomes PAID all the same.

When the payment reaches its final status, the page reports it:

  • opened with window.open from your page (as EasyPay.open() does), it posts { type: "easypay_checkout_complete", sessionToken, status, transactionId } to your window, status being paid or failed;
  • otherwise, about two seconds later, it sends the customer to {returnUrl}?easypay_status=paid&session={sessionToken}&transactionId={id} (failed when the payment was rejected).

If the session has a cancelUrl, the page also shows « Retour à la boutique » (back to the shop) until the customer starts paying, and on the page of an expired or cancelled session. In a popup, the link posts the same message with status cancelled and transactionId null, then closes the popup; otherwise it sends the customer to {cancelUrl}?easypay_status=cancelled&session={sessionToken}. For an expired session, expired replaces cancelled. Leaving does not cancel the session: the customer can come back and pay until expiresAt.

These signals only drive your pages: anyone can type that URL, and a customer may close the page before a pending payment is approved. Fulfil the order on the checkout.paid callback, or after reading the session and finding PAID:

javascript
// returnUrl handler: trust the session you stored, not the query string
app.get("/orders/:id/return", async (req, res) => {
  const order = await findOrder(req.params.id);
  const response = await fetch("https://easypay.emko.ink/api/v1/checkout/sessions/" + order.sessionToken);
  const { data } = await response.json();
  res.render(response.ok && data.status === "PAID" ? "thank-you" : "payment-pending");
});

Read a Session

GET/api/v1/checkout/sessions/{sessionToken}

Public: no API key needed — it is what the payment page calls. Returns the same fields as the creation response, or 404 with "message": "Checkout session not found". The payment itself (transaction id, payment time) is not included: it comes with checkout.paid.

bash
curl https://easypay.emko.ink/api/v1/checkout/sessions/d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2

Session Status Values

OPEN
PAID
EXPIRED
CANCELLED
StatusMeaning
OPENWaiting for payment until expiresAt.
PAIDPaid — checkout.paid is sent to the callbackUrl, if you gave one.
EXPIREDNot paid within 30 minutes. A payment started in time and approved later still turns it PAID.
CANCELLEDCancelled by you. It can no longer be paid.

A session that was not paid reads EXPIRED as soon as expiresAt is past — from this endpoint, in the list below and on the payment page.

List Sessions

GET/api/v1/checkout/sessions

Your sessions with their current status, newest first, live and sandbox together whichever key you use — check sandbox. There is no filter: any other query parameter is ignored.

ParameterTypeRequiredDescription
pagenumberOptionalPage number, from 0. Default: 0
sizenumberOptionalSessions per page. Default: 20, max: 2000
bash
curl "https://easypay.emko.ink/api/v1/checkout/sessions?page=0&size=20" \
  -H "X-API-Key: ek_test_abc123def456"
json
{
  "success": true,
  "message": "Success",
  "data": {
    "content": [
      {
        "sessionToken": "d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2",
        "orderId": "ORDER-1234",
        "amount": 5000.00,
        "status": "PAID",
        "sandbox": true,
        "createdAt": "2026-10-12T14:00:00.750849"
        // … same fields as a single session
      }
    ],
    "totalElements": 42,
    "totalPages": 3,
    "number": 0,
    "size": 20,
    "numberOfElements": 20,
    "first": true,
    "last": false,
    "empty": false
    // … plus "pageable" and "sort" objects
  }
}

Cancel a Session

POST/api/v1/checkout/sessions/{sessionToken}/cancel

No body. Only an OPEN session of your account can be cancelled; nothing is sent to its callbackUrl. Otherwise: 400 with "message": "Only OPEN sessions can be cancelled" or "Session does not belong to this merchant", or 404.

bash
curl -X POST https://easypay.emko.ink/api/v1/checkout/sessions/d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2/cancel \
  -H "X-API-Key: ek_test_abc123def456"
json
{
  "success": true,
  "message": "Checkout session cancelled",
  "data": null
}
Sandbox: create the session with your ek_test_ key — same base URL, same endpoints. It has sandbox: true and the 100 – 500 000 MRU limits do not apply. The customer uses the same payment page; when they pay, the payment is confirmed at once without moving any money, the session becomes PAID and checkout.paid carries "sandbox": true and the X-EasyPay-Sandbox: true header. A customer without an EasyPay account still receives a real SMS code.

The former paths /api/checkout/… still answer, with Deprecation and Sunset: Sun, 18 Apr 2027 headers: use /api/v1/checkout/….

Webhooks

EasyPay notifies your server with signed HTTP POST requests, through two independent mechanisms: the checkout callback, sent to the callbackUrl of a checkout session, and account webhooks, registered once for your merchant account. Fulfil orders from the checkout callback: it is the only one that carries your orderId.

How It Works

1

You create a checkout session with a callbackUrl.

2

When the session is paid, EasyPay sends a signed checkout.paid event to that URL.

3

Your server checks X-EasyPay-Signature — an HMAC-SHA256 of the raw body, keyed with your API key — then marks the order paid.

4

It answers any 2xx within 15 seconds; otherwise EasyPay retries.

Checkout callbackAccount webhooks
Configured withcallbackUrl in POST /api/v1/checkout/sessionsPOST /api/v1/webhooks, merchant session
URLhttps (or http) on a public addresshttps on a public address
Eventscheckout.paidpayment.*, invoice.*, subscription.*
BodyFlat JSON, with your orderId{ event, timestamp, data }, no orderId
Signing keyYour API key (ek_live_… / ek_test_…)The endpoint's whsec_… secret
AttemptsUp to 6, the last about 7 h 20 min after the firstUp to 3, the last about 6 minutes after the first

Both are signed the same way: X-EasyPay-Signature is the lowercase hexadecimal HMAC-SHA256 of the raw request body — no sha256= prefix, and no signature field inside the body. Compute it over the bytes you received, never over JSON you parsed and serialised again: key order, spacing or number formatting would change the result.

Allowed URLs

Both URLs must point to a public internet address. EasyPay refuses a URL whose host is, or resolves to, a loopback, private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), carrier-grade NAT (100.64.0.0/10), link-local (169.254.0.0/16, fe80::/10), unique-local IPv6 (fc00::/7), multicast, unspecified or otherwise reserved address, and a URL that carries credentials (https://user:password@…). Registering such a URL — POST or PUT /api/v1/webhooks, or a checkout session with that callbackUrl — is refused with 400 and the code WEBHOOK_URL_INVALID; the message says why, for example Webhook URL is not allowed: its host resolves to a private network address.

The check runs again before every delivery, on the address the request is then sent to: if your host has started to resolve to such an address, the delivery is dropped: nothing is sent, and EasyPay does not try again. A host that does not resolve yet is accepted at registration. Redirects are not followed — a 3xx answer counts as a failed attempt — so register the final URL. To receive events on your own machine, use a tunnel that gives you a public HTTPS URL (see Testing & Sandbox).

Checkout Callback — checkout.paid

When a checkout session created with a callbackUrl becomes PAID, EasyPay sends one checkout.paid event to that URL. It happens right after a customer pays from the EasyPay app, or later, when you approve a pending payment made by a customer without an EasyPay account — possibly after the session's expiry time: honour it, the money was credited to you. The first attempt leaves within about 30 seconds (deliveries are processed every 30 seconds).

Nothing is sent when a session expires or is cancelled: poll GET /api/v1/checkout/sessions/{sessionToken} (public, no authentication) to close those orders.

Request

http
POST {callbackUrl}
Content-Type: application/json
User-Agent: EasyPay-Webhook/1.0
X-EasyPay-Event: checkout.paid
X-EasyPay-Signature: <lowercase hex HMAC-SHA256 of the raw body, keyed with your API key>
X-EasyPay-Sandbox: true            # only for sessions created with an ek_test_ key

Payload

json
{
  "event": "checkout.paid",
  "sessionToken": "9f2c1e0a…",
  "orderId": "ORDER-42",
  "amount": 4500,
  "currency": "MRU",
  "status": "PAID",
  "transactionId": "3e80e143-752c-4515-92f7-bdd88fecb512",
  "paidAt": "2026-09-09T14:03:12.417",
  "sandbox": false,
  "metadata": "{\"cart_id\":\"abc123\"}",
  "fee": 0,
  "netAmount": 4500,
  "feeBearer": "PAYER"
}

The payload is flat (no data envelope). Amounts are JSON numbers and may be written with decimals (4500.00).

FieldTypeDescription
eventstringAlways checkout.paid.
sessionTokenstringToken of the paid session (the one in the checkout URL). Use it as your idempotency key.
orderIdstring | nullThe orderId you sent when creating the session.
amountnumberSession amount, in MRU.
currencystringMRU.
statusstringAlways PAID.
transactionIdstring (UUID)EasyPay transaction that paid the session.
paidAtstringServer local time, ISO-8601 without timezone.
sandboxbooleantrue for a session created with an ek_test_ key: no real money moved.
metadatastring | nullThe metadata you sent, as a string: an object is stored as its compact JSON string, an empty object as "".
feenumberEasyPay commission deducted from you, in MRU. 0 when the customer paid it on top of the amount.
netAmountnumberWhat you receive, in MRU (amount − fee). Reconcile on this value.
feeBearerstringBENEFICIARY when the commission was deducted from you, otherwise PAYER.

Signature Verification

The key is the API key that created the session — there is no separate webhook secret for the checkout callback. Sessions created with your ek_test_ key are signed with it and carry X-EasyPay-Sandbox: true, which tells you which key to use. Compare in constant time, then parse the JSON.

javascript
const crypto = require("crypto");
const express = require("express");
const app = express();

// Keep the raw bytes: the signature covers the body exactly as EasyPay sent it.
app.post("/easypay/callback", express.raw({ type: "application/json" }), (req, res) => {
  const received = req.get("X-EasyPay-Signature") || "";
  const expected = crypto
    .createHmac("sha256", process.env.EASYPAY_API_KEY) // the key that created the session
    .update(req.body)                                   // Buffer — never JSON.stringify(req.body)
    .digest("hex");

  const valid = received.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "checkout.paid" && event.status === "PAID") {
    markOrderPaid(event.orderId, event.transactionId, event.netAmount); // idempotent on event.sessionToken
  }
  res.sendStatus(200);
});

Retry Policy

Answer with any 2xx within 15 seconds and do the slow work (emails, ERP sync) afterwards. A non-2xx answer, a connection error or no answer within 15 seconds counts as a failure and is retried:

AttemptWhen
1Within about 30 seconds of the payment
21 minute after attempt 1
35 minutes after attempt 2
415 minutes after attempt 3
51 hour after attempt 4
6 (last)6 hours after attempt 5 — then EasyPay stops trying
Best practice: the same event can arrive more than once — for example when your 2xx is lost on the way back. Process each sessionToken once. If your endpoint was down longer than the retry window, read the session with GET /api/v1/checkout/sessions/{sessionToken}.

Account Webhooks

Account webhooks report the payments you collect — QR code, NFC, payment link, checkout, card — plus invoicing and recurring-billing events. They complement the checkout callback; they don't replace it.

Register an endpoint with POST /api/v1/webhooks, authenticated with your merchant session — Authorization: Bearer <accessToken>, obtained with POST /api/v1/auth/login — not with the API key. Up to 5 HTTPS endpoints per account. Signing in from a script counts as a new device: the login is refused (409 SESSION_ACTIVE_ON_ANOTHER_DEVICE) while your EasyPay app is in use, and otherwise signs the app out.

ParameterTypeRequiredDescription
urlstringRequiredHTTPS URL on a public address that receives the events (see Allowed URLs)
eventsstringOptionalComma-separated event names. Default: payment.success,payment.failed
descriptionstringOptionalFree label (max 255 characters)
bash
curl -X POST https://easypay.emko.ink/api/v1/webhooks \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yoursite.com/easypay/webhooks",
    "events": "payment.success,payment.failed",
    "description": "Order system"
  }'
json
{
  "data": {
    "id": "8b0d6f8e-2f4a-4c55-9d0e-6a1f3c2b7e91",
    "url": "https://yoursite.com/easypay/webhooks",
    "events": "payment.success,payment.failed",
    "active": true,
    "description": "Order system",
    "createdAt": "2026-10-12T10:00:00.123",
    "updatedAt": "2026-10-12T10:00:00.123",
    "secret": "whsec_kM9pX2vQ…"
  },
  "message": "Webhook created. Save the secret — it will not be shown again."
}
Important: the secret is returned only in this response. Store it securely: you need it to verify every delivery.

Also available: GET /api/v1/webhooks (list — the secret is never shown again), PUT /api/v1/webhooks/{id} (send url again, plus events, description or active — false pauses deliveries) and DELETE /api/v1/webhooks/{id}.

Event Types

EventSent when
payment.successA payment to you succeeded: paid from the EasyPay app or by card, approved by you (APPROVED_MANUAL) or confirmed by the wallet.
payment.failedA pending payment was rejected, by you (REJECTED_MANUAL) or by the wallet (FAILED). A pending payment that simply expires sends nothing.
payment.updatedNon-final status (PENDING). Reserved: current payment flows only notify final outcomes.
invoice.sentAn invoice was sent to your customer.
invoice.paidAn invoice was paid through its payment link.
invoice.overdueAn invoice passed its due date unpaid.
subscription.createdA customer subscribed to one of your recurring plans.
subscription.billedA billing cycle was charged.
subscription.payment_failedCharging a cycle failed after the retries.
subscription.cancelledA subscription was cancelled.

Webhook Payload Example

Every delivery carries the envelope { event, timestamp, data }, where timestamp is the server's local time (ISO-8601, no timezone) and the headers are the same as above, with the event name in X-EasyPay-Event. For payment.*:

json
{
  "event": "payment.success",
  "timestamp": "2026-10-12T10:15:30.120",
  "data": {
    "transactionId": "3e80e143-752c-4515-92f7-bdd88fecb512",
    "status": "SUCCESS",
    "amount": 4500,
    "currency": "MRU",
    "paymentReference": "QR-1a2b3c4d",
    "paymentMethod": "QR_CODE",
    "senderPhone": "+2223****34",
    "correlationId": "b7c1d2e3-4f5a-4b6c-8d9e-0f1a2b3c4d5e",
    "finalizedAt": "2026-10-12T10:15:29.874",
    "fee": 0,
    "netAmount": 4500,
    "feeBearer": "PAYER"
  }
}
FieldTypeDescription
transactionIdstring (UUID)EasyPay transaction.
statusstringSUCCESS or APPROVED_MANUAL with payment.success; REJECTED_MANUAL or FAILED with payment.failed.
amountnumberPayment amount before fees, in MRU.
currencystringMRU.
paymentReferencestring | nullReference recorded with the payment.
paymentMethodstringChannel recorded with the payment (e.g. QR_CODE, NFC, IN_APP, CARD) — not a closed list.
senderPhonestringPayer's phone number, masked.
correlationIdstring (UUID) | nullTracing identifier — quote it when contacting EasyPay support.
finalizedAtstring | nullWhen the payment reached its final status (server local time, no timezone).
feenumberEasyPay commission deducted from you, in MRU. 0 when the customer paid it on top of the amount.
netAmountnumberWhat you receive, in MRU (amount − fee). Reconcile on this value.
feeBearerstringBENEFICIARY when the commission was deducted from you, otherwise PAYER.

invoice.* events carry invoiceId, invoiceNumber, status, totalAmount, currency and clientName, plus transactionId once paid and dueDate for invoice.overdue. subscription.* events use snake_case names — subscription_id, plan_id, plan_name, then subscriber_id, amount (a string), next_billing_date, transaction_id, billing_cycle, retry_count or cancelled_at depending on the event.

Verification and Delivery

Verify exactly as for the checkout callback, with the endpoint's whsec_… secret as the key, and answer any 2xx within 15 seconds. Each event is sent right after it happens. A failed delivery (non-2xx answer, connection error, no answer within 15 seconds) is retried with the same body, same timestamp included: 1 minute later, then 5 minutes after that — 3 attempts in total. Pausing the endpoint (active: false) or removing the event from its events cancels the pending retries.

After the third failure, or when a retry could not leave within an hour (EasyPay maintenance), the event is not sent again: reconcile with GET /api/v1/transactions (merchant session). The same event can arrive more than once — for example when your 2xx is lost on the way back: deduplicate on event + transactionId.

No sandbox flag: a payment in a sandbox checkout session also triggers payment.success on your account webhooks, and their payload does not say it was a test. Fulfil checkout orders from checkout.paid, which carries sandbox.

WooCommerce Plugin

Accept EasyPay mobile money payments on your WordPress / WooCommerce store with our official plugin. No coding required.

Installation

1

Download the plugin

Download easypay-woocommerce.zip from your Merchant Dashboard under Settings → Integrations.

2

Install in WordPress

Go to Plugins → Add New → Upload Plugin. Select the zip file and click Install.

3

Activate and configure

Activate the plugin, then go to WooCommerce → Settings → Payments → EasyPay.

Configuration

SettingDescription
Enable/DisableToggle EasyPay as a payment method at checkout
TitleLabel shown to customers (e.g. "Pay with Mobile Money")
DescriptionText shown below the payment method at checkout
API KeyYour EasyPay API key: ek_test_... to test, ek_live_... in production. It also verifies the X-EasyPay-Signature of every payment callback, so there is no separate webhook secret.
API URLEasyPay API base URL. Leave the default unless EasyPay support asks you to change it.
Sandbox ModeKeep it checked while you test with an ek_test_... key. When it is unchecked, a test payment never completes an order: the order is put on hold.

Supported Features

Automatic order confirmation (signed callbacks)Refund supportWooCommerce HPOS compatibleSandbox / test modeMulti-currency (MRU)
Signed callbacks (plugin 1.2.0 and later): when a payment completes, EasyPay calls your store back with checkout.paid, signed with your API key. The plugin rejects with HTTP 401 any callback without a valid X-EasyPay-Signature, so knowing your store's callback URL is not enough to mark an order paid. Earlier versions did not check it: update the plugin. If you change your API key, callbacks for payments started with the previous key can no longer be verified: confirm those orders by hand from your EasyPay dashboard.
Requirements: WooCommerce 7.0+ and WordPress 5.8+. PHP 7.4 or higher.

Odoo Module

The easypay_payment module integrates EasyPay with Odoo for seamless invoice and POS payments.

Features

Invoice Payments

Customers pay Odoo invoices via EasyPay checkout

POS Payments

Accept mobile money at point-of-sale terminals

Webhook Auto-Confirmation

Payment status synced automatically via webhooks

Auto-Reconciliation

Invoices reconciled and marked paid automatically

Installation

1

Copy the module

Copy the easypay_payment directory to your Odoo addons path.

2

Update module list

In Odoo, go to Apps → Update Apps List.

3

Install and configure

Search for "EasyPay Payment" in Apps, install it, then configure your API Key and Base URL in Accounting → Configuration → Payment Providers.

Configuration

SettingDescription
API KeyYour EasyPay merchant API key
Base URLAPI base URL (sandbox or https://easypay.emko.ink)
StateTest mode or Production (enabled)
Compatibility: Odoo 16.0+ (Community and Enterprise). The module includes a webhook controller for automatic payment confirmation -- no manual reconciliation needed.

Error Handling

All API errors return a consistent JSON structure. Use the code field for programmatic error handling and the message field for display.

Error Response Format

json
{
  "status": 400,
  "error": "Bad Request",
  "code": "INVALID_AMOUNT",
  "message": "Amount must be greater than 0",
  "details": {
    "field": "amount",
    "rejectedValue": -100,
    "constraint": "must be greater than 0"
  }
}

Common Error Codes

HTTPCodeDescription
400INVALID_AMOUNTAmount is zero, negative, or exceeds limit
400INSUFFICIENT_BALANCEWallet does not have enough funds
400PAYMENT_FAILEDPayment was declined by the wallet provider
401INVALID_API_KEYThe API key is invalid or revoked
401EXPIRED_TOKENJWT access token has expired
403MERCHANT_DISABLEDMerchant account is suspended
403FEATURE_DISABLEDFeature not enabled for your account
404NOT_FOUNDResource does not exist
409SESSION_ALREADY_PAIDCheckout session was already paid
409DUPLICATE_REFERENCEA session with this reference already exists
429RATE_LIMIT_EXCEEDEDToo many requests (100 req/min)
500INTERNAL_ERRORSomething went wrong on our end

Handling Errors in Code

javascript
try {
  const response = await fetch("https://easypay.emko.ink/api/checkout/sessions", {
    method: "POST",
    headers: {
      "X-API-Key": apiKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amount: 5000 }),
  });

  if (!response.ok) {
    const error = await response.json();
    switch (error.code) {
      case "INVALID_API_KEY":
        // Re-authenticate or check key configuration
        break;
      case "RATE_LIMIT_EXCEEDED":
        // Wait and retry with exponential backoff
        break;
      case "DUPLICATE_REFERENCE":
        // Use a different reference or retrieve existing session
        break;
      default:
        console.error(`[${error.code}] ${error.message}`);
    }
    return;
  }

  const data = await response.json();
  // Success -- proceed with data
} catch (err) {
  // Network error
  console.error("Network error:", err);
}

Testing & Sandbox

Test your integration with your ek_test_ key: sandbox checkout sessions go through the same payment page as live ones, but no money moves.

Sandbox Environment

Base URL

https://easypay.emko.ink

The same as in production.

API Key Prefix

ek_test_...
Note: the key, not the URL, decides: a checkout session created with an ek_test_ key is a sandbox session (sandbox: true), one created with an ek_live_ key moves real money. An unknown key returns 401 Invalid API key.

How the Sandbox Behaves

  • The 100 – 500 000 MRU amount limits do not apply.
  • The customer pays on the real payment page, from the EasyPay app or — without an EasyPay account — after confirming their phone number with a real SMS code: use a number that receives SMS. There are no test phone numbers.
  • Every sandbox payment is confirmed at once, without an approval step and without debiting or crediting any wallet. Failures cannot be simulated through the API.
  • The session becomes PAID and checkout.paid is sent with "sandbox": true and the X-EasyPay-Sandbox: true header.

Test Wallets

Any wallet offered by the payment page can be chosen in sandbox — none is debited:

BankilySedadMasrviBFI Cash

Testing Webhooks Locally

To receive webhook events on your local machine during development, use a tunneling service and pass its HTTPS URL as the callbackUrl of your test checkout sessions, or register it as an account webhook:

bash
# Using ngrok
ngrok http 3000

# Register your ngrok URL as an account webhook (merchant session token, not the API key)
curl -X POST https://easypay.emko.ink/api/v1/webhooks \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/api/easypay-webhook",
    "events": "payment.success,payment.failed"
  }'
Sandbox only: All transactions in the sandbox environment are simulated. No real money is charged and no real wallet operations occur. Use sandbox exclusively for development and testing.

Going Live Checklist

Replace the ek_test_... API key with your ek_live_... key — the base URL stays the same

Link a verified wallet to your EasyPay account (a merchant code for a merchant account): sandbox payments credit nothing, so they never check it

Update webhook URLs to your production server

Verify webhook signature verification is implemented

Test a real payment with a small amount (the live minimum is 100 MRU)

Ensure your error handling covers all error codes

Set up monitoring and alerting for webhook failures

Ready to integrate EasyPay?

Create a merchant account and start accepting payments in minutes.