Get Started
Overview
Mpay247 verifies wallet, bank, and UPI payments by matching the customer's transaction ID against the actual SMS your bound Android device receives. We don't process payments — we only confirm them.
This guide walks you through the full integration: from creating your merchant account to having verified transactions land in your dashboard.
Three things make a payment work
- A configured gateway — your wallet/bank account where money lands
- A bound APK on the phone that receives the SMS
- An API call from your backend (or a customer-typed TxnID)
Get Started
Quick Start (5 minutes)
- 1Sign up at /register — pick your country (currency auto-set)
- 2Note your
API Key(Brands page) andDevice Auth Key(Devices page) - 3Add a Gateway — your wallet/bank account number (or last 4 of bank acct)
- 4Install the APK on your shop's phone, paste the Device Auth Key
- 5Test with a checkout session (see the panel beside this list)
The response gives you a checkout_url — open it in a browser, pick a gateway, paste a TxnID, click Verify. That's the whole flow.
Create a checkout session — replace pk_live_... with your key
export MPAY_API_KEY="pk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
curl -X POST https://checkout.mpay247.com/api/payment/sessions \
-H "X-API-Key: $MPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 500, "order_id": "TEST-001", "redirect_url": "https://example.com/done"}'Get Started
How It Works
Mpay247 sits between the merchant's e-commerce backend, the customer's browser, and the bound Android phone receiving wallet SMS.
┌─────────────────────┐ ┌─────────────────────┐
│ Customer's browser │ │ Your bound phone │
│ on yourstore.com │ │ (Mpay247 APK) │
└──────────┬──────────┘ └─────────────────────┘
│ 1) POST /api/payment/sessions │
│ ◄──────── checkout_url ──────── │
▼ │
┌─────────────────────┐ 2) redirect to checkout_url │
│ Mpay247 hosted │ ◄───────────────────────────────── │
│ /pay/<sessionId> │ │
└──────────┬──────────┘ │
│ 3) customer pays, pastes TxnID │
▼ ▼
transaction.status = pending ◄────── wallet SMS arrives
│ │
└──────── 4) match SMS vs TxnID ◄──────────┘
│
▼
transaction.status = success
│ 5) redirect customer back to merchant
▼
┌─────────────────────┐
│ yourstore.com/done │
│ ?status=success │
└─────────────────────┘- 1. Customer hits your checkout — your backend calls
POST /api/payment/sessions - 2. We return a hosted
checkout_url— you redirect the customer - 3. Customer pays from their wallet app, then pastes the TxnID on the checkout and clicks Verify
- 4. Your bound APK forwards the incoming wallet SMS to our backend (pure staging — nothing is auto-created from random SMS)
- 5. The customer's TxnID is matched against the staged SMS — match found, transaction marked Done, customer redirected back to you
Concepts
Authentication
Mpay247 uses three credentials, each for a different actor:
| Credential | Scope | Used by | Purpose |
|---|---|---|---|
| api_key (pk_live_...) | Per brand | Merchant's server | Create checkout sessions, query session status |
| secret_key (sk_live_...) | Per brand | Merchant's server | Sign webhook payloads (future) |
| device_auth_key (PV-…) | Per merchant | The APK on your phone | Bind device, upload SMS, poll for pending verifications |
| JWT (Bearer) | Per session | Merchant dashboard | Logged-in merchant access to /api/merchant/* |
Keep keys server-side
api_key or device_auth_key to the browser. They belong on your backend (api_key) or inside the bound APK (device_auth_key) — never in client-side JavaScript.Concepts
Gateways
A gateway is one wallet/bank account where you receive customer payments — e.g. bKash Personal · 01711111111, or UPI · last-4-of-bank XX6788. Each gateway has an account_number that gets matched against incoming SMS.
Multiple identifiers per gateway
8389834331, XX6788 — we match if any one appears in the SMS body.Concepts
The APK
The Mpay247 Android app runs on the phone you use to receive wallet SMS. It does one job: read incoming wallet SMS and forward them to our backend as data staging. The SMS only becomes a verification when a customer submits a matching TxnID through your checkout — random unrelated SMS sit unused and never become orphan transactions.
The APK also receives verify requests for any pending row a customer submits that hasn't auto-matched yet, so the merchant can Approve / Reject from the phone (mirror of the dashboard's Mark Paid / Mark Failed).
Install the APK on the SIM-receiving device, open it on first launch, paste your Device Auth Key from the dashboard (PV-XXXXXX) — done.
For Android developers building/extending the APK, see the full contract at docs/APK_API.md in the repo.
Integration
Step 1 — Register
Visit /register. Fill in your business details. Your country choice determines your default currency (India → INR, Bangladesh → BDT, US → USD, etc.) — you can override per-session later.
On successful registration, four things are generated for you:
api_key(pk_live_...) — for your backendsecret_key(sk_live_...) — for webhook signatures (future)device_auth_key(PV-XXXXXX) — for binding your phone- A default brand matching your domain
Integration
Step 2 — Configure a Gateway
Go to /dashboard/gateways → Add Gateway → pick your provider (bKash / Nagad / Rocket / Upay, or whatever admin has configured).
The Account Number field must contain what the wallet's SMS will mention — for UPI/bank deposits, that's your bank account suffix (e.g. XX6788), not your GPay phone (the bank SMS doesn't echo your GPay number).
Integration
Step 3 — Bind the APK
Install Mpay247 on the Android phone that receives your wallet SMS. On first launch, paste your device_auth_key (PV-XXXXXX). The phone shows up in /dashboard/devices as Online.
From this moment, every wallet SMS that arrives on the phone gets forwarded to Mpay247 automatically.
Integration
Step 4 — Hosted Checkout
When a customer reaches your checkout, your backend creates a payment session and redirects them to the Mpay247 checkout URL.
// Node.js (Express)
app.post('/checkout', async (req, res) => {
const r = await fetch('https://checkout.mpay247.com/api/payment/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.MPAY_API_KEY,
},
body: JSON.stringify({
amount: req.body.amount,
order_id: req.body.order_id,
redirect_url: 'https://yourstore.com/payment/result',
customer_phone: req.body.customer_phone,
}),
});
const { checkout_url } = await r.json();
res.redirect(checkout_url);
});Session lifecycle
success. If they cancel, it flips to cancelled. Beyond 24 hours, a still-pending session auto-expires.Integration
Step 5 — Verify on Return
After payment, the customer is redirected back to your redirect_url with query params:
https://yourstore.com/payment/result?session_id=eNOuUpsg2IGX4ko4HbFL&status=success&txn_id=9F2K1PQ4SOn a successful payment, txn_id is the transaction ID read from the bank or wallet SMS that confirmed it (bKash TrxID, Nagad TxnID, UPI UTR). It is absent when the payment was confirmed without an SMS, e.g. from a GPay notification. The same value is returned as txn_id by the status API below.
Don't trust query params
?status=success directly without paying. Always verify server-side by calling GET /api/payment/sessions/:id.Heads up — pending returns are normal
?status=pending simply means "not resolved yet" — not "failed". Render a "we're confirming your payment" page and poll GET /sessions/:id server-side until it flips to success / failed / expired (within the 24-hour session window). Treating pending as failure will reject paying customers.app.get('/payment/result', async (req, res) => {
const r = await fetch(
`https://checkout.mpay247.com/api/payment/sessions/${req.query.session_id}`,
{ headers: { 'X-API-Key': process.env.MPAY_API_KEY } }
);
const { session } = await r.json();
if (session.status === 'success') {
await markOrderPaid(session.order_id);
return res.render('order-confirmed', { session });
}
if (session.status === 'pending') {
// Verification didn't finish before redirect. Show a "processing" page;
// keep polling GET /sessions/:id server-side until it resolves or expires.
return res.render('payment-processing', { session });
}
res.render('payment-failed', { reason: session.status });
});Integration
Marketplace / Vendors
If you run a marketplace, each vendor can hold their own gateways and their own bound phone, so payments are verified against the SMS that reaches the vendor who actually made the sale.
- Vendors sign up at /vendor/register and sign in at /vendor/login.
- Each vendor manages their own gateways and device from the vendor dashboard — the same Gateways and APK concepts described above.
- Platform operators review and manage vendors from the admin console.
Vendor endpoints
/api/vendor/* — including /api/vendor/gateways, /api/vendor/devices, /api/vendor/device-key/regenerate and /api/vendor/wallet. These are authenticated with the vendor's own logged-in session, not a merchant pk_live_ API key.API Reference
Payment Sessions
/api/payment/sessionsauth: X-API-KeyCreate a new checkout session for a customer. order_id is enforced unique-per-merchant (see status codes below).
Request body
{
"amount": 500.00,
"order_id": "ORD-1001",
"redirect_url": "https://yourstore.com/done",
"vendor_id": "0b6f3c2e-4d1a-4f7b-9c55-2a8e61d0f9b3", // required — the seller being paid
"currency": "INR",
"customer_name": "rahim_k92", // optional — who is paying, e.g. the buyer's
// username on your site; shown as "From"
// in transaction history. Max 120 characters.
"customer_phone": "+8801712345678",
"metadata": { "cart_id": "cart-42" }
}Response (200)
// 201 Created — fresh session
{
"session_id": "eNOuUpsg2IGX4ko4HbFL",
"checkout_url": "https://mpay247.com/pay/eNOuUpsg2IGX4ko4HbFL",
"expires_at": "2026-05-12T12:00:00Z",
"status": "pending",
"customer_name": "rahim_k92" // echoed back; null if not sent
}
// 400 Bad Request — customer_name longer than 120 characters
{ "error": "customer_name must be at most 120 characters" }
// 200 OK — idempotent: same order_id while a prior session is still alive
{
"session_id": "eNOuUpsg2IGX4ko4HbFL",
"checkout_url": "https://mpay247.com/pay/eNOuUpsg2IGX4ko4HbFL",
"expires_at": "2026-05-12T12:00:00Z",
"status": "pending",
"existed": true
}
// 409 Conflict — order already paid (any prior session is success)
{ "error": "This order has already been paid.", "existing_session_id": "..." }
// 402 Payment Required — merchant wallet too low to cover verification fee
{ "error": "Merchant wallet has insufficient balance. Top up to resume.",
"insufficient_balance": true, "balance": 0, "fee": 2, "threshold": 10 }Handling the response
data.checkout_url regardless of whether you get 201 or 200 — the URL is the same when existed: true. Treat 409 as "this order is done — show the customer the paid state, don't retry." Treat 402 as "merchant configuration issue — surface a friendly retry-later message."/api/payment/sessions/:idauth: X-API-KeyFetch the current status of a session. Use this after the customer returns (and poll it while pending). Always includes latest_transaction — the most recent attempt of ANY status, carrying the UTR, payment method, and the customer’s sender account. successful_transaction is only present once a payment succeeds.
Response (200)
// ── PENDING — submitted, waiting for the SMS/APK to confirm ──
{
"session": {
"id": "eNOuUpsg2IGX4ko4HbFL",
"order_id": "ORD-1001",
"amount": "500.00",
"currency": "BDT",
"status": "pending",
"expires_at": "2026-05-13T11:30:00Z", // 24h after creation
"created_at": "2026-05-12T11:30:00Z",
"txn_id": null, // set once paid — see below
"successful_transaction": null,
"latest_transaction": {
"txn_id": null, // TxnID from the bank/wallet SMS, once approved
"txnid_submitted": null, // reference the customer typed, if any (hint only)
"method": "nagad", // gateway provider (bkash/nagad/rocket/upay)
"variant": "personal",
"account_number": "01711111111", // your receiving wallet
"sender_account": "01822222222", // number the customer paid FROM
"amount": "500.00",
"status": "pending",
"result_source": null,
"failure_reason": null,
"verified_at": null
}
}
}
// ── SUCCESS (auto) — matched against the bound APK's SMS ──
{
"session": {
"id": "eNOuUpsg2IGX4ko4HbFL", "order_id": "ORD-1001",
"amount": "500.00", "currency": "BDT", "status": "success",
"txn_id": "9F2K1PQ4S", // TxnID read from the confirming SMS
// (bKash TrxID / Nagad TxnID / UPI UTR);
// null if confirmed without an SMS
"successful_transaction": {
"txn_id": "9F2K1PQ4S",
"txnid_submitted": null,
"verified_at": "2026-05-12T11:34:10Z",
"result_source": "apk" // or "sms_inbound" / "sms_late_match"
},
"latest_transaction": {
"txn_id": "9F2K1PQ4S", "txnid_submitted": null, "method": "nagad", "variant": "personal",
"account_number": "01711111111", "sender_account": "01822222222",
"amount": "500.00", "status": "success",
"result_source": "apk", "failure_reason": null,
"verified_at": "2026-05-12T11:34:10Z"
}
}
}
// ── SUCCESS (manually approved) — merchant clicked "Mark Paid" ──
// Identical to above, except result_source is "manual".
{
"session": {
"status": "success",
"successful_transaction": { "txnid_submitted": "BKX92H1", "result_source": "manual",
"verified_at": "2026-05-12T11:40:00Z" },
"latest_transaction": { "txnid_submitted": "BKX92H1", "method": "nagad",
"sender_account": "01822222222", "amount": "500.00",
"status": "success", "result_source": "manual",
"failure_reason": null, "verified_at": "2026-05-12T11:40:00Z" }
}
}
// ── FAILED (manually rejected) — merchant clicked "Mark Failed" + reason ──
// NOTE: the transaction is failed, but session.status stays "pending"
// so the customer can still retry with a fresh TxnID.
{
"session": {
"id": "eNOuUpsg2IGX4ko4HbFL", "order_id": "ORD-1001",
"status": "pending",
"successful_transaction": null,
"latest_transaction": {
"txnid_submitted": "BKX92H1", "method": "nagad",
"sender_account": "01822222222", "amount": "500.00",
"status": "failed",
"result_source": "manual", // "apk" if rejected from the phone
"failure_reason": "TxnID not found in our wallet SMS",
"verified_at": "2026-05-12T11:40:00Z"
}
}
}
// ── EXPIRED — no resolution within the 24h window ──
{ "session": { "status": "expired", "successful_transaction": null, "latest_transaction": null } }
// ── CANCELLED — customer abandoned the checkout ──
{ "session": { "status": "cancelled", "successful_transaction": null } }Reading the states
session.status is the order-level state: pending · success · expired · cancelled. latest_transaction.status is the attempt-level verdict: pending · success · failed. A manual rejection fails the transaction but keeps the session pending (so the customer can retry) — so to detect a rejection, read latest_transaction.status === "failed" plus its failure_reason. result_source tells you how it resolved: apk (phone confirmed), sms_inbound / sms_late_match (auto-matched SMS), or manual (merchant Mark Paid/Failed).API Reference
Manual Verify
/api/merchant/verifyauth: Bearer JWT (merchant login)Manually verify a TxnID given to you out-of-band. Used by the dashboard's Verify page. Looks up SMS, extracts amount + gateway, creates a transaction if it all matches.
Request body
{
"txnid": "613384596583"
}Response (200)
{
"matched": true,
"already_existed": false,
"transaction": {
"txnid_submitted": "613384596583",
"amount": 1.00,
"provider": "bkash",
"variant": "personal",
"account_number": "XX6788"
},
"sms": { "sender": "AD-AXISBK-S", "body": "INR 1.00 credited ...", "received_at": "..." }
}API Reference
Devices (APK)
These endpoints are called by your Android APK, authenticated by device_auth_key in the body. Never call from a server or browser.
Wallet-empty (402) handling
/bind and /unbind returns 402 with { insufficient_balance: true, balance, fee, threshold } when the merchant's wallet doesn't have enough to cover the per-verification fee. The APK should switch to a "Wallet empty — please top up" screen and keep heartbeating; when /heartbeat returns 200 again, auto-recover./api/device/bindauth: device_auth_keyRegister a phone with the merchant account. Idempotent — re-binding the same device_id refreshes the row.
Request body
{
"auth_key": "PV-XXXXXX",
"device_id": "android-stable-id",
"model": "CPH1937",
"manufacturer": "OPPO",
"os_version": "11"
}/api/device/unbindauth: device_auth_keyUser taps "Disconnect" in the app. Soft-delete — the row stays in history.
Request body
{ "auth_key": "PV-XXXXXX", "device_id": "android-stable-id" }/api/device/smsauth: device_auth_keyForward incoming wallet SMS. Backend stores the SMS as staging data only — it becomes a verification when a customer submits a matching TxnID through the checkout. Unmatched SMS never become orphan transactions.
Request body
{
"auth_key": "PV-XXXXXX",
"device_id": "android-stable-id",
"messages": [{
"sender": "bKash",
"body": "Cash In Tk 500.00 successful. TrxID: BKX92H1 from 01712345678",
"received_at": "2026-05-12T11:25:30Z"
}]
}/api/device/pollauth: device_auth_keyFetch pending verifications waiting for a decision. Call on a timer (every 10–30s). Each returned row has the data needed to render an Approve/Reject prompt offline.
Request body
{ "auth_key": "PV-XXXXXX", "device_id": "android-stable-id" }Response (200)
{
"verifications": [{
"verification_id": 421,
"txnid_submitted": "BKX92H1",
"amount": 500.00,
"currency": "BDT",
"customer_phone": "01712345678",
"customer_name": "Customer Name",
"order_id": "ORD-9911",
"provider": "bkash",
"variant": "personal",
"account_number": "01799999999",
"created_at": "2026-05-12T11:25:30Z"
}]
}/api/device/reportauth: device_auth_keyResolve a pending verification from the APK ("Approve" / "Reject" buttons). Mirrors the web dashboard's Mark Paid / Mark Failed.
Request body
{
"auth_key": "PV-XXXXXX",
"device_id": "android-stable-id",
"verification_id": 421,
"result": "success",
"matched_sms": "Cash In Tk 500.00 successful. TrxID: BKX92H1...",
"failure_reason": null
}Response (200)
{
"ok": true,
"verification_id": 421,
"status": "success",
"session_id": "Y_giE7l9...",
"transaction": {
"id": 421,
"status": "success",
"result_source": "apk"
}
}/api/device/transactionsauth: device_auth_keyList recent transactions (pending + history) for the merchant on the APK. Filter by status, search by TxnID or order_id.
Request body
{
"auth_key": "PV-XXXXXX",
"device_id": "android-stable-id",
"status": "pending",
"q": "BKX92",
"limit": 50
}Response (200)
{
"transactions": [{
"id": 421,
"txnid_submitted": "BKX92H1",
"amount": 500.00,
"status": "pending",
"result_source": null,
"verified_at": null,
"failure_reason": null,
"provider": "bkash",
"account_number": "01799999999",
"order_id": "ORD-9911",
"customer_name": "Customer Name",
"created_at": "2026-05-12T11:25:30Z"
}]
}/api/device/verifyauth: device_auth_keyPaste a TxnID into the APK. Backend searches received SMS, validates against a configured gateway, creates a successful transaction if everything aligns. Mirrors the web Verify page.
Request body
{
"auth_key": "PV-XXXXXX",
"device_id": "android-stable-id",
"txnid": "BKX92H1"
}Response (200)
{
"matched": true,
"already_existed": false,
"transaction": {
"id": 422, "txnid_submitted": "BKX92H1", "amount": 500.00,
"status": "success", "result_source": "manual_verify",
"provider": "bkash", "account_number": "01799999999"
},
"sms": { "id": 7711, "sender": "bKash", "body": "...", "received_at": "..." }
}Manual verification flow inside the APK
- List view: call
/api/device/transactionsfor full history, or/api/device/pollfor pending-only. - Approve / Reject: call
/api/device/reportwithresult: "success"or"failed". - Type-a-TxnID verify: call
/api/device/verify— searches SMS, auto-resolves.
transactions row that the web dashboard reads, so a decision from the phone shows up on the dashboard immediately.Full APK contract (poll/report patterns, SMS matching rules, permissions) lives in docs/APK_API.md.
API Reference
Errors & Status Codes
| Code | Meaning |
|---|---|
| 200 | OK (existing resource fetched) |
| 201 | Created (new session / new resource) |
| 202 | Accepted (TxnID submitted, verification pending) |
| 400 | Bad request — check error message; usually a missing/invalid field |
| 401 | Invalid or missing API key (X-API-Key or JWT) |
| 402 | Merchant wallet has insufficient balance — top up to resume |
| 403 | Forbidden — usually merchant suspended |
| 404 | Resource not found (wrong id, or not yours) |
| 409 | Conflict — duplicate (TxnID reused, order already paid, session in wrong state) |
| 429 | Too many requests — back off, see Rate Limits below |
| 5xx | Server error — retry with exponential backoff |
Error response shape
Customer-facing endpoints (/api/payment/sessions, /api/checkout/:id/submit) split every error into two tiers so you can safely route the right text to each audience:
{
"error": "Services currently unavailable.",
"merchant_message": "Merchant wallet has insufficient balance to cover the per-verification fee. Top up at the dashboard.",
"insufficient_balance": true,
"code": "merchant_wallet_empty"
}error— safe to display to your customer. Neutral, no Mpay247-specific language, no numeric leaks.merchant_message— for your server logs / admin alerts only. Tells you exactly what to fix. Never render this to the end customer.code— stable machine-readable identifier (e.g.merchant_wallet_empty,order_already_paid). Use this for programmatic branching.- Other fields like
insufficient_balanceorexisting_session_idare programmatic hints — safe but uninformative if shown.
Don't dump the whole response to the customer
alert(JSON.stringify(response)) on the customer's screen. They see "Merchant wallet has insufficient balance" and lose trust in your site. Always read error and display only that.Reference handler (Node):
const r = await fetch(...);
const body = await r.json();
if (!r.ok) {
// Log the technical detail for ops
console.warn('[mpay247]', body.merchant_message || body.error, '— code:', body.code);
// Show ONLY the safe text to the customer
return res.status(503).json({ error: body.error });
}API Reference
Rate Limits
Mpay247 throttles every public endpoint to protect against brute force, TxnID mining, and runaway integrations. When you exceed a limit you get HTTP 429 instead of the normal response — the request did not run.
Per-endpoint limits
| Endpoint | Limit | Window | Keyed by |
|---|---|---|---|
| POST /api/payment/sessions | 60 | 1 min | API key |
| POST /api/checkout/:id/submit | 6 | 1 min | IP + session |
| POST /api/merchant/login | 8 | 15 min | IP |
| POST /api/merchant/verify | 20 | 1 min | IP |
| POST /api/device/sms | 120 | 1 min | device_auth_key |
429 response shape
Every throttled response carries a body explaining the retry window and headers a polite client can read.
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716210000
Retry-After: 37
{
"error": "API rate limit exceeded. Slow down session creation.",
"retry_after_seconds": 37
}X-RateLimit-Limit— the cap for this endpoint.X-RateLimit-Remaining— how many calls you have left in the current window.X-RateLimit-Reset— Unix timestamp when the counter resets.Retry-After— seconds to wait before retrying (also in the body as retry_after_seconds).
429 does NOT mean the action failed
Polite retry pattern (Node)
async function callWithBackoff(url, init, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const r = await fetch(url, init);
if (r.status !== 429) return r;
// Server told us how long to wait; honour it (cap at 60s).
const ra = Number(r.headers.get('Retry-After')) ||
(await r.clone().json().then(b => b.retry_after_seconds).catch(() => 1));
const waitMs = Math.min(60, Math.max(1, ra)) * 1000;
if (attempt === maxAttempts) return r; // give up after last attempt
await new Promise((res) => setTimeout(res, waitMs));
}
}Need a higher limit?
API Reference
Troubleshooting
Issues integrators hit most often, with the fix.
PHP cURL: SSL routines:ssl3_read_bytes:tlsv1 unrecognized name
TLS handshake error. The client didn't send a Server Name Indication (SNI) the cert recognizes, and old OpenSSL (≤ 1.0.x, common in PHP 5.x / 7.0–7.3) treats it as fatal instead of a warning. Three checks, in order:
- Hostname must be exact. Use
https://checkout.mpay247.com— not an IP, notwww., not a CDN alias. Calling by IP doesn't send SNI at all. - Don't bypass DNS. Do NOT set
CURLOPT_RESOLVEor pass--resolveon the CLI — both can send the wrong SNI name. - Force TLS 1.2 and keep verification on. Don't disable
CURLOPT_SSL_VERIFYPEERorCURLOPT_SSL_VERIFYHOST— that often makes the problem worse, not better.
Working PHP example:
<?php
$ch = curl_init('https://checkout.mpay247.com/api/payment/sessions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: pk_live_...',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 1.00,
'order_id' => 'ORD-1',
'redirect_url' => 'https://yoursite.com/payment/result',
'customer_phone' => '7557012345',
'customer_name' => 'Customer Name',
]),
CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
error_log('Mpay247 cURL error: ' . curl_error($ch));
}
curl_close($ch);
$body = json_decode($response, true);
header('Location: ' . $body['checkout_url']);Still failing? Test from the shell first:
curl -v https://checkout.mpay247.com/api/providerscurlworks, PHP fails → upgrade PHP/OpenSSL (PHP ≥ 7.4 with OpenSSL ≥ 1.1.1 fixes it permanently).- Both fail with the same SNI error → you're on the wrong hostname, or behind a corporate proxy mangling SNI.
- Both succeed but your code still errors → it's your request body, not TLS. Check the response status & body.
404 from /api/payment/sessions
You're hitting the wrong host. Mpay247 has two services — the dashboard UI and the API — on different domains. The API base URL is https://checkout.mpay247.com. Pointing your backend at the dashboard URL returns 404 because the dashboard doesn't serve the API.
Sanity check: curl https://checkout.mpay247.com/api/providers should return JSON with the provider list (no auth needed). If it does, the URL is correct; if it doesn't, fix the base URL first.
401 Invalid or expired token
You sent the wrong header for the wrong endpoint:
- Backend-to-Mpay247 (
/api/payment/...): useX-API-Key: pk_live_... - Merchant dashboard / your own JWT calls (
/api/merchant/...): useAuthorization: Bearer <jwt> - APK calls (
/api/device/...): use theauth_keyfield in the JSON body.
Trailing-slash double-slash //api/...
If your config stores the base URL with a trailing slash and your code appends /api/payment/..., you'll request https://checkout.mpay247.com//api/payment/sessions. Some nginx setups normalize this; others 404. Strip the trailing slash from your base URL.
402 insufficient_balance
Mpay247 charges merchants a small per-verification fee, debited from the merchant's wallet on every successful verification. When the wallet drops below that fee, the merchant's operations are paused:
- New
POST /api/payment/sessionscalls return 402 — xyz.com can't create new checkouts. - APK endpoints (poll/sms/report/verify/heartbeat) return 402 — the phone shows a "Wallet empty" screen.
/bindand/unbindstay available so the phone can disconnect/reconnect.
Fix: log into the merchant dashboard → Wallet → Add Balance → pay via wallet. Operations resume on the next request after the recharge confirms.
409 "This order has already been paid"
You called POST /sessions with an order_id that already has a successful session. Don't retry — fetch the existing session via existing_session_id in the error body and treat the order as fulfilled.
Transactions stuck on Pending
The customer submitted a TxnID but no matching SMS has reached the backend. Two common causes:
- No bound device. Check Dashboard → Devices. An "Online" row means the APK is heartbeating; it does NOT guarantee SMS is being forwarded. If the SMS log (Dashboard → SMS) is empty, the APK isn't uploading.
- Gateway identifier mismatch. The number you set on the gateway must appear in the SMS body. For SMS that show only a masked bank suffix (e.g.
XX6788), store multiple identifiers comma-separated.
As a fallback, the merchant can resolve any pending row from Dashboard → Transactions (Mark Paid / Mark Failed). Customers still on the checkout page (within ~15s of submitting) see the decision and are redirected; customers who've already returned to your site with status=pending pick up the new state through your backend's polling of GET /sessions/:id.
Production
Best Practices
- 1. Always verify server-side. Don't trust query params on return — call
GET /sessions/:idfrom your backend before fulfilling. - 2. Keep keys out of git + browser. Store
api_keyin env vars; never log it. - 3. Set
customer_phoneon the session. Tightens SMS matching, reduces false positives dramatically. - 4. Use HTTPS for
redirect_url. No exceptions in production. - 5. Configure multiple identifiers per gateway. Mobile + bank suffix (comma-separated) means new banks rolling out won't silently stop working.
- 6. Monitor
/dashboard/devices. If the bound phone goes offline, verifications queue but don't complete. Set a backup phone if you can. - 7. Keep your wallet topped up. Each successful verification debits a small fee from your wallet balance. Below the per-verification fee, all your endpoints return 402 and your APK is paused. The dashboard shows an amber warning before you hit empty — top up then. Wallet →
- 8. Handle the order_id idempotency response. POST /sessions can return 200 (with
existed:true) when the same order_id has a live pending session — use the URL it returns, don't create a parallel checkout.
Production
Webhooks (Coming Soon)
Today, you confirm payments by polling GET /sessions/:id after the customer returns.
Webhooks are on the roadmap. When live, Mpay247 will POST to a URL on your domain whenever a session resolves, signed with HMAC-SHA256 of the body using your brand's secret_key:
POST https://yourstore.com/mpay247/webhook
X-Mpay247-Signature: t=1683900000,v1=<hmac-sha256-hex>
{ "event": "session.success", "session": { ... } }Until then, polling on customer return is the recommended pattern.
Production
Support
Questions? Run into something the docs don't cover?
- 📧 Email: support@mpay247.com
- 🐛 Bugs / API issues: open a ticket from your dashboard
- 📖 APK developer reference:
docs/APK_API.md - 📖 Integration deep-dive:
docs/INTEGRATION.md

