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
| Environment | Base URL | Key Prefix |
|---|---|---|
| Sandbox | https://easypay.emko.ink | ek_test_... |
| Production | https://easypay.emko.ink | ek_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/jsonCurrency
MRU (Mauritanian Ouguiya)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.
| Credential | Used 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 Type | Prefix | Usage |
|---|---|---|
| Live Key | ek_live_ | Real payments. |
| Test Key | ek_test_ | Sandbox checkout sessions, on the same base URL: no money moves (see Testing & Sandbox). |
Example: list your checkout sessions
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".
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
phone | string | Required | Phone number of the account, international format: +222… |
pin | string | Required | PIN of the account (4 digits). |
deviceId | string | Optional | Identifies your client as one device — up to 64 letters, digits, dots, dashes, underscores or colons. Send the same value at every login. |
deviceName | string | Optional | Readable name of that device, up to 80 characters. |
# 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..."| Token | Lifetime and use |
|---|---|
accessToken | Valid 15 minutes (expiresIn: 900, in seconds). Send it as Authorization: Bearer …; once expired, the API answers 401. |
refreshToken | Valid 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 out | POST /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).
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.
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.
// 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:
payButton.addEventListener("click", () => {
EasyPay.open(checkoutUrl).then(onResult, onError);
});| Argument | Description |
|---|---|
target | The 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.popupFallback | When 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.
| status | Meaning |
|---|---|
paid | The payment page reports the payment as successful. |
failed | The payment was rejected. |
cancelled | The customer closed the popup, or used « Retour à la boutique » (shown when the session has a cancelUrl). The session stays OPEN until it expires. |
expired | The customer left an expired session with « Retour à la boutique ». |
- The result only drives your page: confirm the order on your server, from the
checkout.paidcallback 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 oncheckout.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.
<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
| Method | What 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.
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:
| Authentication | Used 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
/api/v1/checkout/sessions/api/v1/checkout/sessions/{sessionToken}/api/v1/checkout/sessions/api/v1/checkout/sessions/{sessionToken}/cancelQR Codes
/api/v1/qr-codes/api/v1/qr-codes/api/v1/qr-codes/{id}/api/v1/qr-codes/{id}/image/api/v1/qr-codes/{id}/api/v1/qr-codes/{id}Payment Links
/api/v1/payment-links/api/v1/payment-links/api/v1/payment-links/{id}/api/v1/payment-links/{id}/api/v1/payment-links/slug/{slug}Transactions
/api/v1/transactionsFees
/api/v1/fees/quote/api/v1/fees/policieswalletQuoteAvailable: 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
/api/v1/webhooks/api/v1/webhooks/api/v1/webhooks/{id}/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.
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
| Endpoint | Authentication |
|---|---|
POST /api/v1/checkout/sessions | X-API-Key — create a session |
GET /api/v1/checkout/sessions/{sessionToken} | None (public) — read a session |
GET /api/v1/checkout/sessions | X-API-Key — list your sessions |
POST /api/v1/checkout/sessions/{sessionToken}/cancel | X-API-Key — cancel an open session |
checkout.paid callbacks: anyone who reads it can forge them.Create a Session
/api/v1/checkout/sessionsRequest Headers
| Parameter | Type | Required | Description |
|---|---|---|---|
X-API-Key | string | Required | Your API key: ek_live_… for real payments, ek_test_… for sandbox sessions. Missing → 400, unknown → 401. |
Content-Type | string | Required | application/json |
Session Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | Required | Shown to the customer on the payment page. Up to 255 characters. |
amount | number | Required | Amount 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. |
returnUrl | string | Required | Where 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. |
currency | string | Optional | MRU — the default and the only currency supported. |
description | string | Optional | Shown on the payment page under the title. |
orderId | string | Optional | Your order reference, sent back in the checkout.paid callback. Up to 255 characters. |
metadata | string | object | Optional | Free 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. |
callbackUrl | string | Optional | Server-to-server URL that receives the signed checkout.paid callback (see Webhooks). Up to 2048 characters. |
cancelUrl | string | Optional | Where 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.
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)
{
"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"
}
}| Field | Description |
|---|---|
sessionToken | 64 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. |
checkoutUrl | The EasyPay payment page of the session, {base URL}/checkout/{sessionToken}. Send the customer there. |
id | Session UUID. The other endpoints take the sessionToken, not this id. |
status | OPEN at creation — see the lifecycle below. |
expiresAt | createdAt plus 30 minutes. Server local time, ISO-8601 without timezone. |
sandbox | true for a session created with an ek_test_ key. |
createdAt | When the session was created, in the same format. Reading or listing the session returns the same value. |
merchantName, userId | Your business name as the customer sees it, and your EasyPay account id. |
| Other fields | As you sent them. amount is read back with two decimals (5000.00). metadata and callbackUrl are not returned. |
Errors
| Status | When |
|---|---|
| 400 | title, amount or returnUrl missing or blank, or amount not above 0 — "detail": "Invalid request content." (the response does not name the field). |
| 400 | Body that is not valid JSON, or a value of the wrong type — "detail": "Failed to read request". Without Content-Type: application/json: 415. |
| 400 | No X-API-Key header — "detail": "Required header 'X-API-Key' is not present." |
| 400 | Live key and an amount outside the limits — "message": "Le montant minimum est de 100 MRU" (or maximum). |
| 401 | Unknown 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 becomesPAIDall the same.
When the payment reaches its final status, the page reports it:
- opened with
window.openfrom your page (asEasyPay.open()does), it posts{ type: "easypay_checkout_complete", sessionToken, status, transactionId }to your window,statusbeingpaidorfailed; - otherwise, about two seconds later, it sends the customer to
{returnUrl}?easypay_status=paid&session={sessionToken}&transactionId={id}(failedwhen 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:
// 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
/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.
curl https://easypay.emko.ink/api/v1/checkout/sessions/d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2Session Status Values
| Status | Meaning |
|---|---|
OPEN | Waiting for payment until expiresAt. |
PAID | Paid — checkout.paid is sent to the callbackUrl, if you gave one. |
EXPIRED | Not paid within 30 minutes. A payment started in time and approved later still turns it PAID. |
CANCELLED | Cancelled 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
/api/v1/checkout/sessionsYour 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
page | number | Optional | Page number, from 0. Default: 0 |
size | number | Optional | Sessions per page. Default: 20, max: 2000 |
curl "https://easypay.emko.ink/api/v1/checkout/sessions?page=0&size=20" \
-H "X-API-Key: ek_test_abc123def456"{
"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
/api/v1/checkout/sessions/{sessionToken}/cancelNo 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.
curl -X POST https://easypay.emko.ink/api/v1/checkout/sessions/d3628f0cb1c74e42ac93a508a5609d9c455802651a2647f4b94b77f1b12e2df2/cancel \
-H "X-API-Key: ek_test_abc123def456"{
"success": true,
"message": "Checkout session cancelled",
"data": null
}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
You create a checkout session with a callbackUrl.
When the session is paid, EasyPay sends a signed checkout.paid event to that URL.
Your server checks X-EasyPay-Signature — an HMAC-SHA256 of the raw body, keyed with your API key — then marks the order paid.
It answers any 2xx within 15 seconds; otherwise EasyPay retries.
| Checkout callback | Account webhooks | |
|---|---|---|
| Configured with | callbackUrl in POST /api/v1/checkout/sessions | POST /api/v1/webhooks, merchant session |
| URL | https (or http) on a public address | https on a public address |
| Events | checkout.paid | payment.*, invoice.*, subscription.* |
| Body | Flat JSON, with your orderId | { event, timestamp, data }, no orderId |
| Signing key | Your API key (ek_live_… / ek_test_…) | The endpoint's whsec_… secret |
| Attempts | Up to 6, the last about 7 h 20 min after the first | Up 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
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_ keyPayload
{
"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).
| Field | Type | Description |
|---|---|---|
event | string | Always checkout.paid. |
sessionToken | string | Token of the paid session (the one in the checkout URL). Use it as your idempotency key. |
orderId | string | null | The orderId you sent when creating the session. |
amount | number | Session amount, in MRU. |
currency | string | MRU. |
status | string | Always PAID. |
transactionId | string (UUID) | EasyPay transaction that paid the session. |
paidAt | string | Server local time, ISO-8601 without timezone. |
sandbox | boolean | true for a session created with an ek_test_ key: no real money moved. |
metadata | string | null | The metadata you sent, as a string: an object is stored as its compact JSON string, an empty object as "". |
fee | number | EasyPay commission deducted from you, in MRU. 0 when the customer paid it on top of the amount. |
netAmount | number | What you receive, in MRU (amount − fee). Reconcile on this value. |
feeBearer | string | BENEFICIARY 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.
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:
| Attempt | When |
|---|---|
| 1 | Within about 30 seconds of the payment |
| 2 | 1 minute after attempt 1 |
| 3 | 5 minutes after attempt 2 |
| 4 | 15 minutes after attempt 3 |
| 5 | 1 hour after attempt 4 |
| 6 (last) | 6 hours after attempt 5 — then EasyPay stops trying |
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Required | HTTPS URL on a public address that receives the events (see Allowed URLs) |
events | string | Optional | Comma-separated event names. Default: payment.success,payment.failed |
description | string | Optional | Free label (max 255 characters) |
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"
}'{
"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."
}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
| Event | Sent when |
|---|---|
payment.success | A payment to you succeeded: paid from the EasyPay app or by card, approved by you (APPROVED_MANUAL) or confirmed by the wallet. |
payment.failed | A pending payment was rejected, by you (REJECTED_MANUAL) or by the wallet (FAILED). A pending payment that simply expires sends nothing. |
payment.updated | Non-final status (PENDING). Reserved: current payment flows only notify final outcomes. |
invoice.sent | An invoice was sent to your customer. |
invoice.paid | An invoice was paid through its payment link. |
invoice.overdue | An invoice passed its due date unpaid. |
subscription.created | A customer subscribed to one of your recurring plans. |
subscription.billed | A billing cycle was charged. |
subscription.payment_failed | Charging a cycle failed after the retries. |
subscription.cancelled | A 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.*:
{
"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"
}
}| Field | Type | Description |
|---|---|---|
transactionId | string (UUID) | EasyPay transaction. |
status | string | SUCCESS or APPROVED_MANUAL with payment.success; REJECTED_MANUAL or FAILED with payment.failed. |
amount | number | Payment amount before fees, in MRU. |
currency | string | MRU. |
paymentReference | string | null | Reference recorded with the payment. |
paymentMethod | string | Channel recorded with the payment (e.g. QR_CODE, NFC, IN_APP, CARD) — not a closed list. |
senderPhone | string | Payer's phone number, masked. |
correlationId | string (UUID) | null | Tracing identifier — quote it when contacting EasyPay support. |
finalizedAt | string | null | When the payment reached its final status (server local time, no timezone). |
fee | number | EasyPay commission deducted from you, in MRU. 0 when the customer paid it on top of the amount. |
netAmount | number | What you receive, in MRU (amount − fee). Reconcile on this value. |
feeBearer | string | BENEFICIARY 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.
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
Download the plugin
Download easypay-woocommerce.zip from your Merchant Dashboard under Settings → Integrations.
Install in WordPress
Go to Plugins → Add New → Upload Plugin. Select the zip file and click Install.
Activate and configure
Activate the plugin, then go to WooCommerce → Settings → Payments → EasyPay.
Configuration
| Setting | Description |
|---|---|
| Enable/Disable | Toggle EasyPay as a payment method at checkout |
| Title | Label shown to customers (e.g. "Pay with Mobile Money") |
| Description | Text shown below the payment method at checkout |
| API Key | Your 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 URL | EasyPay API base URL. Leave the default unless EasyPay support asks you to change it. |
| Sandbox Mode | Keep 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
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.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
Copy the module
Copy the easypay_payment directory to your Odoo addons path.
Update module list
In Odoo, go to Apps → Update Apps List.
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
| Setting | Description |
|---|---|
| API Key | Your EasyPay merchant API key |
| Base URL | API base URL (sandbox or https://easypay.emko.ink) |
| State | Test mode or Production (enabled) |
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
{
"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
| HTTP | Code | Description |
|---|---|---|
400 | INVALID_AMOUNT | Amount is zero, negative, or exceeds limit |
400 | INSUFFICIENT_BALANCE | Wallet does not have enough funds |
400 | PAYMENT_FAILED | Payment was declined by the wallet provider |
401 | INVALID_API_KEY | The API key is invalid or revoked |
401 | EXPIRED_TOKEN | JWT access token has expired |
403 | MERCHANT_DISABLED | Merchant account is suspended |
403 | FEATURE_DISABLED | Feature not enabled for your account |
404 | NOT_FOUND | Resource does not exist |
409 | SESSION_ALREADY_PAID | Checkout session was already paid |
409 | DUPLICATE_REFERENCE | A session with this reference already exists |
429 | RATE_LIMIT_EXCEEDED | Too many requests (100 req/min) |
500 | INTERNAL_ERROR | Something went wrong on our end |
Handling Errors in Code
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.inkThe same as in production.
API Key Prefix
ek_test_...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
PAIDandcheckout.paidis sent with"sandbox": trueand theX-EasyPay-Sandbox: trueheader.
Test Wallets
Any wallet offered by the payment page can be chosen in sandbox — none is debited:
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:
# 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"
}'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.