Integration docs

mbpay confirms bKash, Nagad, Rocket, Upay, and Tap deposits by matching customer-submitted transaction IDs against SMS received on your operator's Android device. mbpay never custodies funds — it just tells your server when the money has actually arrived.

You'll be live in under 15 minutes if you use the mbpay npm package; raw HTTP recipes for Node / PHP / curl are at the bottom.

Sign requests
HMAC-SHA256 over timestamp + raw body. ±5 min skew.
Get notified
Signed webhook on match. Retries up to 10× with exponential backoff.
Stay safe
Replay-protected, key rotation in one click, no PCI scope.

Overview

Here's the full lifecycle of a single deposit:

  1. Your customer hits the deposit page on your site. You call getPaymentOptions() to learn which providers are live; one wallet is picked from your pool per (provider, channel) pair.
  2. Customer chooses the method + amount. You call initiatePayment() and redirect them to mbpay's hosted checkout (checkout_url).
  3. On the hosted checkout, the customer pays from their MFS app, then pastes the TrxID into a single field and submits.
  4. Your operator's Android phone (running the mbpay APK) forwards the bank's confirmation SMS to mbpay. The match engine confirms (txn id + amount + provider + device) within seconds.
  5. mbpay POSTs a signed webhook to your webhookUrl— your server credits the customer's order.

1. Get your keys

Open /dashboard/brands Create brand. Right after creation the dashboard shows three values once:

Public keypk_test_… / pk_live_…Identifies your brand on every API call. Sent in `X-Brand-Key`. Safe to commit.
Secret keysk_test_… / sk_live_…Signs every outgoing API call. Never expose to a browser.
HMAC secret64-char hexmbpay signs webhooks with this. Use it to verify inbound deliveries.
Save them now — they aren't recoverable.

Lost a secret? Click Rotate keys on the brand page — old values are invalidated immediately, new ones are shown once.

2. Install the SDK

The mbpay npm package handles HMAC signing, webhook verification, and all the response types so your code stays terse. Node 18+ required.

bash
# npm
npm install mbpay

# pnpm
pnpm add mbpay

# yarn
yarn add mbpay
.env
# .env  (server-side only — never expose secret or hmac keys to the browser)
MBPAY_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MBPAY_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MBPAY_HMAC_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MBPAY_API_BASE_URL=http://localhost:4000

Build a shared client and import it everywhere:

lib/mbpay.ts
// lib/mbpay.ts — one shared client for your whole app
import { Mbpay } from 'mbpay';

export const mbpay = new Mbpay({
  publicKey:  process.env.MBPAY_PUBLIC_KEY!,
  secretKey:  process.env.MBPAY_SECRET_KEY!,
  hmacSecret: process.env.MBPAY_HMAC_SECRET!,
  apiBaseUrl: process.env.MBPAY_API_BASE_URL,
});
Zero runtime dependencies (uses Node's built-in fetch + crypto). Bundle size: ~5 KB minified.
Claude Code

Scaffold with Claude Code

Skip the manual setup — paste one of these prompts into Claude Code from your project root and it'll install the SDK, set up your env file, build the deposit page, wire the webhook, and respect your existing code style. Each prompt is self-contained — no need to read the rest of this page first.

paste into claude code
You're helping me integrate mbpay (a Bangladeshi MFS payment gateway: bKash, Nagad, Rocket, Upay, Tap) into my Next.js App Router project. mbpay confirms payments by matching customer-submitted transaction IDs against SMS received on the merchant's Android device, then fires a signed webhook to my server.

Use the published npm package `mbpay`. API base URL: http://localhost:4000

Do these in order:

1. Install the SDK:
   ```
   npm install mbpay
   ```

2. Add these env vars to my `.env.local` (template values — I'll fill in real ones from /dashboard/brands):
   ```
   MBPAY_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
   MBPAY_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
   MBPAY_HMAC_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
   MBPAY_API_BASE_URL=http://localhost:4000
   ```

3. Create `lib/mbpay.ts` exporting a single shared client:
   ```ts
   import { Mbpay } from 'mbpay';
   export const mbpay = new Mbpay({
     publicKey: process.env.MBPAY_PUBLIC_KEY!,
     secretKey: process.env.MBPAY_SECRET_KEY!,
     hmacSecret: process.env.MBPAY_HMAC_SECRET!,
     apiBaseUrl: process.env.MBPAY_API_BASE_URL,
   });
   ```

4. Create the deposit page at `app/deposit/page.tsx` (server component):
   - Calls `mbpay.getPaymentOptions()` to list active providers.
   - Renders one button per (provider, channel) pair showing `channel_label` + the wallet phone number.
   - Has an amount input.
   - On submit, calls a server action (`app/deposit/actions.ts`) that runs `mbpay.initiatePayment({ mfsAccountId: <picked wallet.id>, amount, endUserRef: <orderId>, successUrl, failUrl })` and redirects to `payment.checkout_url`.

5. Create the webhook receiver at `app/api/mbpay-webhook/route.ts`:
   - POST handler.
   - Reads raw body with `const rawBody = await req.text();` — CRITICAL: do NOT use `req.json()` because the HMAC is computed over the exact bytes.
   - Calls `mbpay.verifyWebhook(rawBody, req.headers.get('x-mbpay-signature'))`.
   - On valid signature, parses JSON: `const event = JSON.parse(rawBody) as import('mbpay').WebhookPayload;`
   - Calls a `creditOrderIdempotent(event.end_user_ref, event.amount, event.id)` helper — implement it to update my orders table, deduplicating on `event.id` (mbpay retries up to 10×).
   - Returns `new Response('ok', { status: 200 })` on success, `new Response('bad signature', { status: 401 })` on verify failure.

6. After this works locally, I'll deploy and set my public webhook URL in the mbpay dashboard at /dashboard/brands → Webhook URL.

Constraints:
- All mbpay calls happen server-side. NEVER expose secretKey or hmacSecret to the browser.
- Send amount with 2 decimals (500.00, not 500) — the matcher compares against the SMS body byte-for-byte.
- The webhook handler MUST be idempotent — dedupe on `event.id` or you'll double-credit.
- Match the existing TypeScript style, file structure, and lint rules of this project.
- Don't ask clarifying questions — read the existing codebase to figure out conventions.
Before you paste: have your project open in Claude Code and cd into the root.
After it finishes: open /dashboard/brands, copy your real keys, and paste them into the env file Claude created.
Not seeing your framework? Use the Next.js prompt as a template and tell Claude "adapt this for <your framework> instead" — the integration shape is the same.

3. End-to-end flow

Three SDK calls take you from "customer clicked Deposit" to "redirected to the hosted checkout":

app/api/deposit/route.ts
import { mbpay } from '@/lib/mbpay';

// 1. On every deposit-page render, list the brand's live payment options.
//    `wallet` inside each option is randomly load-balanced across the active
//    MFS pool for that (provider, channel) combo — display it as-is.
const options = await mbpay.getPaymentOptions();
// → [{ provider, channel, channel_label, wallet: { id, phone_number, label }, wallet_count }]

// 2. When the user picks an option and submits the amount:
const payment = await mbpay.initiatePayment({
  mfsAccountId: options[0].wallet.id,
  amount:       500.00,
  endUserRef:   'order_42',                       // echoed back in the webhook
  successUrl:   'https://example.com/orders/42',  // optional — user lands here on success
  failUrl:      'https://example.com/orders/42',  // optional — user lands here on cancel
  expiresInMinutes: 10,                           // optional, default 10, max 180
});

// 3. Redirect the user's browser to mbpay's hosted checkout.
return Response.redirect(payment.checkout_url, 303);

The HTTP response from initiatePayment looks like:

json
201 Created
{
  "payment": {
    "id":           "cmpo...abc",
    "reference":    "mb_a1b2c3d4e5f6",
    "provider":     "bkash",
    "amount":       "500.00",
    "status":       "pending",
    "mfs_account":  { "phone_number": "01749663880", "label": "Primary bKash" },
    "expires_at":   "2026-05-28T14:30:00.000Z",
    "checkout_url": "https://app.mbpay.bd/checkout/cmpo...abc"
  }
}
Field details
  • mfsAccountId — the id of the wallet from getPaymentOptions(). Required.
  • amount — BDT, up to 2 decimals. Must match the SMS exactly (500 ≠ 500.00 for matching).
  • endUserRef — your order id or user id. We echo it back on the webhook. Required, max 200 chars.
  • successUrl / failUrl — optional. Where mbpay sends the user from the hosted checkout.
  • expiresInMinutes — optional, default 10, max 180. After this the payment is auto-marked expiredand won't match.

4. Hosted checkout

Redirect the customer's browser to payment.checkout_url. The hosted page (branded with your logo if you uploaded one) shows:

  • The wallet phone number to send to
  • The exact amount expected (matched against the SMS, byte-for-byte)
  • A TrxID input + Submit button
  • A 3-minute soft countdown after submit + live polling indefinitely

When the match lands the page flips to a premium receipt with a Close button (no automatic redirect — the customer can leave when they want). If the customer closes the page early, mbpay still fires the webhook — they'll see the credit on your order page when they come back.

5. Webhooks

When a payment matches, mbpay POSTs a signed event to your brand's webhookUrl. The HMAC is computed with your HMAC secret(different from your API secret key). Retries up to 10× with exponential backoff if your endpoint doesn't reply 2xx within 10 seconds.

http
POST https://your-domain.com/mbpay/webhook
content-type: application/json
x-mbpay-signature: t=1716906321,v1=ab12cd34ef56...
x-mbpay-delivery-attempt: 1

{
  "id":           "cmpo...abc",
  "brand_id":     "cmpo...brand",
  "reference":    "mb_a1b2c3d4e5f6",
  "amount":       "500.00",
  "provider":     "bkash",
  "txn_id":       "DET3Q9YHB1",
  "end_user_ref": "order_42",
  "matched_at":   "2026-05-28T14:05:21.000Z",
  "status":       "matched"
}

Use the SDK's verifyWebhook — it runs a timing-safe HMAC comparison and rejects timestamps outside ±5 minutes:

typescript
// app/api/mbpay-webhook/route.ts  (Next.js App Router)
import type { NextRequest } from 'next/server';
import type { WebhookPayload } from 'mbpay';
import { mbpay } from '@/lib/mbpay';
import { creditOrderIdempotent } from '@/lib/orders';

export async function POST(req: NextRequest): Promise<Response> {
  // CRITICAL: capture the raw bytes — the signature is computed over them.
  const rawBody = await req.text();
  const sig     = req.headers.get('x-mbpay-signature');

  if (!mbpay.verifyWebhook(rawBody, sig)) {
    return new Response('bad signature', { status: 401 });
  }

  const event = JSON.parse(rawBody) as WebhookPayload;
  // event.status === 'matched'
  // event.end_user_ref is YOUR reference (the orderId you sent in initiatePayment)
  // event.txn_id is the bKash/Nagad TrxID the user submitted
  //
  // Idempotency: mbpay retries up to 10 times with exponential backoff.
  // Your handler MUST tolerate duplicates — dedupe on event.id.
  await creditOrderIdempotent(event.end_user_ref, event.amount, event.id);

  // Return 200 within 10s to mark delivery successful.
  return new Response('ok', { status: 200 });
}
Idempotency: mbpay may resend the same event if your endpoint times out. Always dedupe on event.id (or event.end_user_ref + your own check).
Raw body: capture the unparsed bytes before any JSON middleware touches them — re-serializing breaks the signature.
Manual match: operators can mark a payment matched from the agent dashboard if the SMS pipeline failed but they verified the deposit. From your perspective the webhook fires identically — just credit the order.

6. Polling fallback

Use this only when you cannot receive inbound webhooks (e.g. corporate firewall). Webhooks are always preferred — they're instant and free.

typescript
// Optional fallback when your endpoint can't accept inbound webhooks.
import { mbpay } from '@/lib/mbpay';

async function waitForMatch(paymentId: string, timeoutMs = 5 * 60_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const p = await mbpay.getPaymentStatus(paymentId);
    if (p.status === 'matched') return p;
    if (p.status === 'expired' || p.status === 'failed') {
      throw new Error(`payment ${p.status}`);
    }
    await new Promise((r) => setTimeout(r, 3000));   // 3s between polls
  }
  throw new Error('timeout');
}

Raw HTTP response shape:

http
GET /api/v1/payment/cmpo...abc

{
  "payment": {
    "id":              "cmpo...abc",
    "reference":       "mb_a1b2c3d4e5f6",
    "provider":        "bkash",
    "expectedAmount":  "500.00",
    "status":          "matched",
    "submittedTxnId":  "DET3Q9YHB1",
    "endUserRef":      "order_42",
    "expiresAt":       "2026-05-28T14:30:00.000Z",
    "matchedAt":       "2026-05-28T14:05:21.000Z",
    "createdAt":       "2026-05-28T14:00:00.000Z",
    "mfsAccount":      { "phoneNumber": "01749663880", "label": "Primary bKash" },
    "brand":           { "name": "Your Store" }
  }
}

7. Errors & retries

All errors are returned as JSON with this shape:

json
{ "error": "validation_failed", "message": "Invalid request body", "details": { … } }

The SDK throws MbpayError on any non-2xx; you can read err.status and err.body off it.

HTTPerror codeWhen
400validation_failedZod validation rejected the body.
400bad_requestGeneric bad input (e.g. unknown wallet id).
401unauthorizedBad signature, stale timestamp (>5 min skew), or replay detected.
404not_foundPayment id or wallet id does not belong to your brand.
409conflictTrxID already used elsewhere; payment already matched / expired; wallet has payment history (delete blocked).
429rate_limitedAuthentication rate-limit (50 requests / 15 min per IP).

8. Reference

Payment status values

pendingCreated. Customer hasn't submitted a TrxID yet.
txn_submittedCustomer submitted; waiting for the matching SMS to arrive.
matchedConfirmed. Webhook fired. Done.
expiredPast expires_at without a match. Final.
failedExplicit failure (rare; e.g. agent suspended mid-flow).

SDK methods

getPaymentOptions()Promise<PaymentOption[]>Active (provider, channel) pairs with a randomly-picked wallet per pair.
initiatePayment(input)Promise<InitiateResponse>Creates a payment + returns checkout_url to redirect the user to.
getPaymentStatus(id)Promise<PaymentStatusResponse>Poll for the current status of a payment.
verifyWebhook(rawBody, sig)booleantrue iff the HMAC matches and timestamp is within ±5 min.

9. Without the SDK

If you're not on Node, sign every /api/v1/* request with three headers. The signature is the hex HMAC-SHA256 of `${timestamp}.${rawBody}` using your secret key. GET uses an empty body ("").

typescript
// Manual signing — only if you can't or don't want to use the SDK.
// Sign EVERY /api/v1/* request with three headers.
import { createHmac } from 'node:crypto';

const PUBLIC_KEY  = process.env.MBPAY_PUBLIC_KEY!;
const SECRET_KEY  = process.env.MBPAY_SECRET_KEY!;
const API_BASE    = 'http://localhost:4000';

export async function mbpayFetch(path: string, body?: unknown) {
  const payload = body === undefined ? '' : JSON.stringify(body);
  const ts      = Math.floor(Date.now() / 1000).toString();
  const sig     = createHmac('sha256', SECRET_KEY)
    .update(`${ts}.${payload}`)
    .digest('hex');

  const res = await fetch(`${API_BASE}${path}`, {
    method: body === undefined ? 'GET' : 'POST',
    headers: {
      'x-brand-key': PUBLIC_KEY,
      'x-timestamp': ts,
      'x-signature': sig,
      ...(body === undefined ? {} : { 'content-type': 'application/json' }),
    },
    body: body === undefined ? undefined : payload,
  });
  if (!res.ok) {
    const err = await res.text();
    throw new Error(`mbpay ${res.status}: ${err}`);
  }
  return res.json();
}

Common pitfalls

  • Body bytes must match. Sign exactly what you POST. Don't prettify, re-serialize, or reorder keys between signing and sending — even a single different byte breaks the signature.
  • Amount precision. Always send two decimals (500.00, not 500). The matcher compares against the SMS body and the bank prints two decimals.
  • Clock skew. If you see Timestamp outside acceptable skew, your server clock is more than 5 minutes off. Sync NTP. (Especially common on spun-up containers.)
  • Webhook idempotency. mbpay retries up to 10× on non-2xx — dedupe on event.id on your side or you'll double-credit.
  • Replay rejection. Each signed request can only be sent once — replays return 401 Replay detected. If you retry a failed request, sign it fresh (new timestamp).
  • Webhook URLs. mbpay blocks webhook URLs that resolve to private / loopback / link-local addresses in production. Use a public HTTPS endpoint.
  • Rotating keys. Rotating invalidates the old secret + HMAC immediately. Update your env vars first, then rotate.