Back to BlogArchitecture & Engineering

Integrating bKash, Nagad and SSLCommerz in One Checkout

Rupak Amin

Founder & Lead Engineer, RAITHub

11 min read

Put every rail behind one payment interface in your backend, and trust none of their browser redirects. For bKash, create and execute the payment on your server and reuse its one-hour token instead of requesting one per order. For SSLCommerz, confirm every payment through its validation API and check amount, currency and transaction ID. Mark an order paid only after that check.

This guide is for teams selling in Bangladesh who need mobile wallets, cards and cash on delivery in one checkout. The bKash and SSLCommerz details below come from their official developer documentation, checked on 29 September 2026. We could not open a public version of Nagad's API documentation, so this post does not describe Nagad's calls; the same rules apply to it, and you should work from Nagad's own merchant documentation.

Why do Bangladeshi stores need several payment rails?

Because customers pay in different ways and no single rail covers them all. Mobile wallets such as bKash and Nagad, a payment aggregator such as SSLCommerz for cards and other methods, and cash on delivery each reach a different group of buyers. Stripe, the default elsewhere, does not list Bangladesh among its supported countries, as covered in Stripe Connect for marketplaces.

TheSkinProof, the founder's own verified-skincare marketplace built and run by RAITHub rather than a client project, runs all four: bKash, Nagad, SSLCommerz and cash on delivery. It has 217 API endpoints and 750+ automated tests, and its pricing is server-authoritative: the browser is never trusted for an amount.

RailHow the customer paysWhat your server must do before marking the order paid
bKash tokenized checkoutRedirected to a bKash page to enter wallet number and PINCall Execute Payment and check transactionStatus is Completed
SSLCommerzRedirected to SSLCommerz's hosted gateway pageCall the Order Validation API with val_id; check status, amount, currency and tran_id
NagadPer Nagad's merchant documentationA server-side confirmation from Nagad, with the same amount and ID checks
Cash on deliveryPays the courier at the doorNothing at checkout; reconcile when the courier remits the cash

How should one checkout handle several gateways?

With one small interface that every rail implements, so the order code never knows which gateway it is talking to. Each rail starts a payment and later confirms it; the order is updated by one shared function.

export type Confirmed = { amountMinor: number; currency: 'BDT'; providerTxnId: string }

export interface PaymentRail {
  /** Starts a payment and returns the URL to send the customer to. */
  start(order: { id: string; totalMinor: number; phone: string }): Promise<string>
  /** Confirms with the provider, server to server. Null means not paid. */
  confirm(ref: string): Promise<Confirmed | null>
}

// Amounts are stored in paisa (integers). Gateways take decimal taka.
export const toTaka = (minor: number) => (minor / 100).toFixed(2)
export const toMinor = (taka: string) => Math.round(Number(taka) * 100)

Record every attempt before redirecting the customer: order ID, rail, the provider's reference and the amount you asked for. When a confirmation arrives, you look up the attempt, not the order the browser claims to be paying for.

How does bKash tokenized checkout work?

In two parts: a token for your server, and a create-then-execute flow for each payment.

Tokens: get one, then refresh it

bKash's token documentation says "by default, the token lifetime is 3600 seconds", and tells merchants to call the Refresh Token API "before the end of the current token lifetime (at the 50th/55th minute)". It also warns: "Do not call this API more than two times within an hour. If you exceed this limit, the API will return an error, and you will be blocked for one hour" (bKash: token management). So cache the token in one place shared by all your server instances, and never request a new one per order. On serverless hosting, where many short-lived instances run at once, that shared cache is not optional.

Payments: create, redirect, execute

Create Payment is a POST to {base_URL}/tokenized/checkout/create with the token in the Authorization header and your app key in X-App-Key. The base URL is shared by bKash during onboarding. The response includes a paymentID, which bKash says is valid for 24 hours and cannot be reused after execution, and a bkashURL to send the customer to (bKash: create payment). After the customer enters their PIN, bKash redirects them to your callback URL with paymentID and status set to success, failure or cancel. You then call Execute Payment at {base_URL}/tokenized/checkout/execute, and a successful payment returns a trxID and a transactionStatus of Completed (bKash: execute payment).

The create-payment reference shows the agreement flow, where the customer first agrees to let your site remember their bKash account, with mode set to 0001 and an agreementID. A minimal sketch of that flow; getBkashToken is your shared token cache.

const BASE = process.env.BKASH_BASE_URL! // shared by bKash during onboarding

async function bkash(path: string, body: unknown) {
  const res = await fetch(BASE + path, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      Authorization: await getBkashToken(),
      'X-App-Key': process.env.BKASH_APP_KEY!,
    },
    body: JSON.stringify(body),
  })
  return res.json()
}

export async function startBkash(order: { id: string; totalMinor: number }, agreementID: string) {
  const r = await bkash('/tokenized/checkout/create', {
    mode: '0001',
    agreementID,
    callbackURL: 'https://shop.example.com/pay/bkash/callback',
    amount: toTaka(order.totalMinor),
    currency: 'BDT',
    intent: 'sale',
    merchantInvoiceNumber: order.id,
  })
  if (r.statusCode !== '0000') throw new Error('bKash create failed: ' + r.statusCode)
  await saveAttempt(order.id, 'bkash', r.paymentID, order.totalMinor)
  return r.bkashURL as string
}

// GET /pay/bkash/callback?paymentID=...&status=success
export async function confirmBkash(paymentID: string, status: string): Promise<Confirmed | null> {
  if (status !== 'success') return null
  const r = await bkash('/tokenized/checkout/execute', { paymentID })
  if (r.transactionStatus !== 'Completed') return null
  return { amountMinor: toMinor(r.amount), currency: 'BDT', providerTxnId: r.trxID }
}

Check the full request fields and error codes against bKash's reference for your account type before going live; the sketch shows the shape, not every field.

How does SSLCommerz integration work?

Your server opens a session, the customer pays on SSLCommerz's hosted page, and your server confirms the result through the validation API. All three steps are in the SSLCommerz v4 developer documentation.

  1. Create a session. A form-encoded POST to /gwprocess/v4/api.php, on sandbox.sslcommerz.com for testing or securepay.sslcommerz.com for live. Required fields include store_id, store_passwd, total_amount, currency, a unique tran_id, the success_url, fail_url and cancel_url, customer details, and product fields that the docs mark as mandatory: shipping_method, product_name, product_category and product_profile. The docs call ipn_url "not mandatory, however better to use".
  2. Redirect. A successful response has status of SUCCESS and a GatewayPageURL to send the customer to.
  3. Confirm. SSLCommerz POSTs an IPN (instant payment notification) to your ipn_url with the status, val_id, tran_id and amount. Call the Order Validation API, a GET to /validator/api/validationserverAPI.php with val_id, store_id and store_passwd, and check that the status is VALID or VALIDATED. The documentation tells you to validate the amount, currency and tran_id against your own records.
const SSL = process.env.SSLCZ_BASE_URL! // https://sandbox.sslcommerz.com or https://securepay.sslcommerz.com

export async function confirmSslcommerz(valId: string): Promise<Confirmed & { tranId: string } | null> {
  const q = new URLSearchParams({
    val_id: valId,
    store_id: process.env.SSLCZ_STORE_ID!,
    store_passwd: process.env.SSLCZ_STORE_PASSWD!,
    format: 'json',
  })
  const v = await fetch(SSL + '/validator/api/validationserverAPI.php?' + q).then((r) => r.json())
  if (v.status !== 'VALID' && v.status !== 'VALIDATED') return null
  if (v.currency !== 'BDT') return null
  return { tranId: v.tran_id, amountMinor: toMinor(v.amount), currency: 'BDT', providerTxnId: v.bank_tran_id }
}

VALIDATED means the transaction was already confirmed once, which is normal when the IPN and the success redirect both trigger a check. That is why marking the order paid has to be safe to run twice.

How do you mark an order paid safely?

With one conditional update that checks the order is still awaiting payment and that the confirmed amount matches what the order costs. If it updates no row, the order was already paid, or the amount was wrong, and you log it for review instead of shipping.

UPDATE orders
SET status = 'paid', paid_at = now(), payment_rail = $3, provider_txn_id = $4
WHERE id = $1
  AND status = 'awaiting_payment'
  AND total_minor = $2
RETURNING id;

Run it from both the redirect handler and the server-to-server notification. Whichever arrives first marks the order; the second updates nothing and returns quietly. Send confirmation emails and SMS after the commit, so a slow message provider never holds the payment transaction open. The general pattern is in idempotency in API design, and if notifications never arrive at all, the debugging steps in why a webhook is not firing apply to IPNs too.

How does cash on delivery fit in the same checkout?

As a rail with no gateway. The order is confirmed at checkout, stock is reserved, and the money arrives later through the courier. The order is not paid until the courier's remittance has been matched to it, so keep "confirmed" and "paid" as separate states and reconcile remittances order by order. TheSkinProof books deliveries with Pathao and Steadfast from its admin console, so the courier records and the order records live in one system.

What goes wrong in bKash and SSLCommerz integrations?

  • Trusting the success redirect. A customer can open your success URL by hand. Only a server-to-server confirmation proves payment.
  • Not checking the amount. A valid payment of the wrong amount is still the wrong payment.
  • A new bKash token per order. It adds a call to every checkout and risks bKash's hourly limit on token calls.
  • Reusing transaction IDs. Use a fresh tran_id or invoice number per attempt, and map it back to the order.
  • Floating-point taka. Store paisa as integers and convert only at the gateway boundary.
  • No sandbox tests in CI. Each rail's success, failure, cancel and "confirmed twice" paths need automated tests.

Why RAITHub for this

  • All four rails in production. TheSkinProof, the founder's own venture, runs bKash, Nagad, SSLCommerz and cash on delivery. PadhAI, an AI tutoring platform RAITHub built, supports 9 payment gateways.
  • Money paths tested. Payment confirmation, amount checks and double delivery of notifications are covered by automated tests gated in CI.
  • Fixed scope, your code. A free 15-minute technical audit, then a written fixed quote. You own the code; an NDA is standard. See the API and backend development service.

When you don't need us

  • Your store platform has a maintained plugin for these gateways. Use it, and test it with each gateway's sandbox.
  • You need one rail and have a backend developer. The official documentation linked above is enough to build a single integration carefully.
  • You need merchant account approval or licensing advice. That is between you and the gateway or your adviser; RAITHub builds the integration, not the merchant agreement.

More on payments architecture is in the payments engineering guide, the full platform is on the TheSkinProof case study, and ecommerce builds are on the ecommerce industry page. To plan yours, book the free 15-minute technical audit with the rails you need and your order flow.

Last reviewed: 29 September 2026. bKash and SSLCommerz developer documentation checked on 29 September 2026.

Frequently asked questions

How do I integrate bKash into my website?

Use bKash's tokenized checkout: get a token and refresh it before its one-hour lifetime ends, call Create Payment, redirect the customer to the bkashURL, and on your callback call Execute Payment. Mark the order paid only when transactionStatus is Completed.

How long is a bKash token valid?

bKash's documentation says the token lifetime is 3,600 seconds by default, and recommends refreshing it at around the 50th or 55th minute rather than requesting a new token for each payment.

How do I verify an SSLCommerz payment?

Call the Order Validation API with the val_id from the IPN, your store_id and store_passwd. Accept a status of VALID or VALIDATED, and check the amount, currency and tran_id against your own order.

Is the SSLCommerz IPN URL required?

The documentation marks ipn_url as not mandatory but recommends using it. Without it you depend on the customer's browser returning to your success URL, which does not always happen.

Can bKash, Nagad, SSLCommerz and cash on delivery run in one checkout?

Yes. Put each behind the same start and confirm interface, record every payment attempt, and mark orders paid through one conditional update. TheSkinProof runs all four in one checkout.

What is the difference between VALID and VALIDATED in SSLCommerz?

VALID is a successful payment; VALIDATED means the transaction has already been confirmed. Treat both as paid, and make your order update safe to run twice.

bkash integrationbkash tokenized checkoutsslcommerz integrationnagad payment gatewaybangladesh payment gatewaycash on delivery

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.