Developer Documentation

Mpay247 Integration Guide

Everything you need to integrate SMS-based wallet payment verification into your ecommerce flow. Step-by-step, with copy-pasteable code in Node, PHP, Python and cURL.

  • Easy Integration
  • Secure & Reliable
  • 24/7 Developer Support
Mpay247 payment verification: a phone confirming a successful payment, surrounded by card, security and API elements

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

Get Started

Quick Start (5 minutes)

  1. 1
  2. 2
  3. 3Add a Gateway — your wallet/bank account number (or last 4 of bank acct)
  4. 4Install the APK on your shop's phone, paste the Device Auth Key
  5. 5Test with a checkout session (see the panel beside this list)

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.
  • 2.
  • 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.

Concepts

Authentication

Mpay247 uses three credentials, each for a different actor:

CredentialScopeUsed byPurpose
api_key (pk_live_...)Per brandMerchant's serverCreate checkout sessions, query session status
secret_key (sk_live_...)Per brandMerchant's serverSign webhook payloads (future)
device_auth_key (PV-…)Per merchantThe APK on your phoneBind device, upload SMS, poll for pending verifications
JWT (Bearer)Per sessionMerchant dashboardLogged-in merchant access to /api/merchant/*

Keep keys server-side

Concepts

Gateways

Multiple identifiers per gateway

Concepts

The APK

Integration

Step 1 — Register

On successful registration, four things are generated for you:

Integration

Step 2 — Configure a Gateway

Integration

Step 3 — Bind the APK

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

Integration

Step 5 — Verify on Return

text
https://yourstore.com/payment/result?session_id=eNOuUpsg2IGX4ko4HbFL&status=success&txn_id=9F2K1PQ4S

Don't trust query params

Heads up — pending returns are normal

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.

Vendor endpoints

API Reference

Payment Sessions

POST/api/payment/sessionsauth: X-API-Key

Create a new checkout session for a customer. order_id is enforced unique-per-merchant (see status codes below).

Request body

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

json
// 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

GET/api/payment/sessions/:idauth: X-API-Key

Fetch 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)

json
// ── 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

API Reference

Manual Verify

POST/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

json
{
  "txnid": "613384596583"
}

Response (200)

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

Wallet-empty (402) handling

POST/api/device/bindauth: device_auth_key

Register a phone with the merchant account. Idempotent — re-binding the same device_id refreshes the row.

Request body

json
{
  "auth_key":     "PV-XXXXXX",
  "device_id":    "android-stable-id",
  "model":        "CPH1937",
  "manufacturer": "OPPO",
  "os_version":   "11"
}
POST/api/device/unbindauth: device_auth_key

User taps "Disconnect" in the app. Soft-delete — the row stays in history.

Request body

json
{ "auth_key": "PV-XXXXXX", "device_id": "android-stable-id" }
POST/api/device/smsauth: device_auth_key

Forward 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

json
{
  "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"
  }]
}
POST/api/device/pollauth: device_auth_key

Fetch 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

json
{ "auth_key": "PV-XXXXXX", "device_id": "android-stable-id" }

Response (200)

json
{
  "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"
  }]
}
POST/api/device/reportauth: device_auth_key

Resolve a pending verification from the APK ("Approve" / "Reject" buttons). Mirrors the web dashboard's Mark Paid / Mark Failed.

Request body

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

json
{
  "ok":              true,
  "verification_id": 421,
  "status":          "success",
  "session_id":      "Y_giE7l9...",
  "transaction": {
    "id":            421,
    "status":        "success",
    "result_source": "apk"
  }
}
POST/api/device/transactionsauth: device_auth_key

List recent transactions (pending + history) for the merchant on the APK. Filter by status, search by TxnID or order_id.

Request body

json
{
  "auth_key":  "PV-XXXXXX",
  "device_id": "android-stable-id",
  "status":    "pending",
  "q":         "BKX92",
  "limit":     50
}

Response (200)

json
{
  "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"
  }]
}
POST/api/device/verifyauth: device_auth_key

Paste 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

json
{
  "auth_key":  "PV-XXXXXX",
  "device_id": "android-stable-id",
  "txnid":     "BKX92H1"
}

Response (200)

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

The APK has the same verification powers as the web dashboard:

API Reference

Errors & Status Codes

CodeMeaning
200OK (existing resource fetched)
201Created (new session / new resource)
202Accepted (TxnID submitted, verification pending)
400Bad request — check error message; usually a missing/invalid field
401Invalid or missing API key (X-API-Key or JWT)
402Merchant wallet has insufficient balance — top up to resume
403Forbidden — usually merchant suspended
404Resource not found (wrong id, or not yours)
409Conflict — duplicate (TxnID reused, order already paid, session in wrong state)
429Too many requests — back off, see Rate Limits below
5xxServer error — retry with exponential backoff

Error response shape

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

Don't dump the whole response to the customer

Reference handler (Node):

js
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

EndpointLimitWindowKeyed by
POST /api/payment/sessions601 minAPI key
POST /api/checkout/:id/submit61 minIP + session
POST /api/merchant/login815 minIP
POST /api/merchant/verify201 minIP
POST /api/device/sms1201 mindevice_auth_key

429 response shape

Every throttled response carries a body explaining the retry window and headers a polite client can read.

http
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

A 429 means the request did not run at all. It is safe to retry after waiting Retry-After seconds. For /sessions, slow your integration. For /submit, ask the customer to wait a moment and try again — don't auto-retry on their behalf or you'll just hit the limit harder.

Polite retry pattern (Node)

js
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?

These caps fit normal merchant traffic with plenty of headroom. If you have a legitimate burst use case (bulk reconciliation, migration, etc.) email support before you launch — raising your limit ahead of time is easier than recovering from blocked traffic.

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:

Working PHP example:

php
<?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:

bash
curl -v https://checkout.mpay247.com/api/providers
  • 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

401 Invalid or expired token

You sent the wrong header for the wrong endpoint:

Trailing-slash double-slash //api/...

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:

  • APK endpoints (poll/sms/report/verify/heartbeat) return 402 — the phone shows a "Wallet empty" screen.

409 "This order has already been paid"

Transactions stuck on Pending

The customer submitted a TxnID but no matching SMS has reached the backend. Two common causes:

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

Production

Webhooks (Coming Soon)

http
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?

  • 🐛 Bugs / API issues: open a ticket from your dashboard