One way to take money,four ways to receive it.

BDPayments is a small Node.js library that talks to Stripe, SSLCommerz, bKash and Nagad using the exact same instructions. Learn it once, and switching or adding a payment provider becomes a one-word change.

0 gateways Node 18+ No required dependencies TypeScript included 0 tests

Never done this before? Start here.

Skip ahead if you already integrate payment gateways for a living.

The travel-adapter analogy. Every country has a different wall socket. Rather than buying a new charger for each one, you carry a single universal adapter.

Payment providers are the sockets. Each one speaks its own language, needs its own passwords, and returns answers in its own shape. BDPayments is the adapter that lets your code plug into any of them the same way.

Some words you'll see everywhere

Word What it actually means
Gateway A company that moves money for you — Stripe, bKash, Nagad, SSLCommerz.
Charge Asking for money from a customer.
Refund Giving some or all of it back.
Transaction ID The receipt number. Keep it — you need it to refund or check a payment later.
Credentials Your secret keys. They prove the money should go to your bank account.
Sandbox A pretend mode for testing. Fake money, real behaviour. Always start here.
Callback / IPN The gateway phoning your server back to say "that payment went through".

The one rule that matters most: never treat an order as paid just because the customer's browser came back to your success page. Anyone can type that address. Always ask the gateway directly with retrieve(). This page will remind you again where it counts.

Everything is one of four actions

The entire library is these four verbs. Every gateway uses the same ones.

charge()

Ask the customer for money. Returns a receipt, or a link to send them to.

execute()

Finish a payment the customer already approved. bKash only.

refund()

Send money back — all of it, or part.

retrieve()

Ask the gateway "did this actually happen?". Your source of truth.

Every one of them answers in the same shape, no matter which gateway you used:

{
  success: true,              // did it work?
  transactionId: 'pi_3Nk...', // the receipt number — save this
  status: 'succeeded',        // the gateway's own wording
  amount: 1000,
  currency: 'usd',
  gatewayResponse: { ... }    // the untouched raw reply, if you need it
}

Install

npm install bdpayments

That's everything for SSLCommerz, bKash and Nagad — they need no extra software. Only add Stripe's own package if you use Stripe:

npm install stripe   # only if you use Stripe

Forget it and you won't get a confusing crash — you'll get a plain message saying The "stripe" peer dependency is not installed. Run: npm install stripe.

The one decision to make first

Before writing any code, work out which of these describes you. It changes where your secret keys live — and almost nothing else.

Mode A

Single company

You run one business. Money from every customer lands in your bank account.

  • One online shop or restaurant
  • One school collecting fees
  • One SaaS billing its own subscribers

Set your keys once at startup and never think about them again. → Show me

Mode B

Multi-tenant (SaaS / marketplace)

You built a platform. Each business on it has its own merchant account, and money goes to them, not you.

  • A shop-builder hosting 500 stores
  • A food-delivery app paying restaurants
  • A booking system for many clinics

Pass that tenant's keys with each payment.Show me

You can do both at once. Set your own keys globally as a fallback, and override them per payment for tenants who have brought their own. That is exactly how the priority order in How keys resolve is designed to work.

Side by side

Single company Multi-tenant
Who gets the money You Each tenant
How many key sets One One per tenant
Where keys live Env vars or configure() Your database, encrypted
How you pass them Once at startup On every call
Setup effort Two minutes An afternoon — you also need onboarding + encrypted storage

Mode A — Single company

One business, one set of keys. Tell BDPayments about them once when your app boots.

Where the money goes

Customer 1 Your account
Customer 2 Your account
Customer 3 Your account

Every payment ends up in the same place, so one set of keys covers everything.

  1. Put your keys in environment variables

    Never type secret keys directly into your code — anyone who reads your repository would see them. Keep them in a .env file that you never commit.

    # .env  — add this file to .gitignore!
    STRIPE_API_KEY=sk_test_...
    
    BKASH_APP_KEY=...
    BKASH_APP_SECRET=...
    BKASH_USERNAME=...
    BKASH_PASSWORD=...
    BKASH_SANDBOX=true

    That alone is enough — BDPayments reads these automatically. The next step is only if you prefer setting them in code.

  2. Or call configure() once at startup

    Useful when your keys come from a secrets manager rather than the environment.

    import { configure } from 'bdpayments';
    
    configure({
      stripe: { apiKey: process.env.STRIPE_API_KEY },
      bkash: {
        appKey:    process.env.BKASH_APP_KEY,
        appSecret: process.env.BKASH_APP_SECRET,
        username:  process.env.BKASH_USERNAME,
        password:  process.env.BKASH_PASSWORD,
        sandbox:   true,
      },
    });
  3. Take a payment — no keys needed here

    From now on every call finds the credentials by itself.

    import { charge } from 'bdpayments';
    
    const payment = await charge({
      gateway: 'bkash',
      amount: 500,
      invoiceNumber: 'INV-001',
      callbackURL: 'https://myshop.com/bkash/callback',
    });
    
    // Send the customer here to approve the payment
    console.log(payment.bkashURL);

A typo like configure({ strip: … }) is rejected immediately with a clear message, instead of quietly failing later with "missing credentials".

Mode B — Multi-tenant SaaS

Many businesses on one platform, each with their own merchant account. Money must reach the right one.

What "multi-tenant" means. Imagine you built software that lets anyone open an online shop. Five hundred shops now run on it. When a customer buys from Rahim's Bakery, that money must land in Rahim's bKash account — not yours, and certainly not the bakery next door's.

So there is no single "the" key. Each shop has its own, and you pick the right one for every single payment.

Where the money goes

Order at Rahim's Rahim's bKash
Order at Karim's Karim's bKash
Order at Fatima's Fatima's bKash

Three separate destinations, so three separate sets of keys — and the lanes must never cross.

  1. Collect each tenant's keys when they sign up

    Rahim opens his own bKash merchant account and pastes those keys into your dashboard. Store them encrypted — they are as sensitive as passwords.

    // A tenants table, credentials encrypted at rest
    await db.tenants.update(tenantId, {
      bkash: encrypt({
        appKey:    '...',
        appSecret: '...',
        username:  '...',
        password:  '...',
        sandbox:   false,
      }),
    });
  2. Look them up and pass them with the payment

    Keys given directly to a call always win over anything global. This single feature is the whole of multi-tenant support.

    import { charge } from 'bdpayments';
    
    async function chargeForTenant(tenantId, order) {
      const creds = decrypt(await db.tenants.getBkash(tenantId));
    
      return charge({
        gateway: 'bkash',
    
        // --- this tenant's credentials ---
        appKey:    creds.appKey,
        appSecret: creds.appSecret,
        username:  creds.username,
        password:  creds.password,
        sandbox:   creds.sandbox,
    
        // --- the payment itself ---
        amount: order.total,
        invoiceNumber: order.id,
        callbackURL: `https://platform.com/cb/${tenantId}`,
      });
    }
  3. Works identically for every gateway

    Same idea, different key names. Nothing else about your code changes.

    // Stripe — one tenant, one account
    await charge({ gateway: 'stripe', apiKey: creds.apiKey,
                   amount: 1000, currency: 'usd' });
    
    // SSLCommerz — one tenant, one store
    await charge({ gateway: 'sslcommerz',
                   storeId: creds.storeId, storePassword: creds.storePassword,
                   sandbox: false, amount: 1000, transactionId: 'TXN-1',
                   successUrl: '...', failUrl: '...', cancelUrl: '...' });
    
    // Nagad — one tenant, one merchant + key pair
    await charge({ gateway: 'nagad',
                   merchantId: creds.merchantId,
                   publicKey: creds.publicKey, privateKey: creds.privateKey,
                   amount: 500, orderId: 'ORD-1' });

Built to keep tenants apart

Speeding things up must never mean mixing two tenants' money. Here is what the library does about that, so you don't have to:

Login sessions kept separate

bKash requires a login token before each action. Tokens are cached per tenant, so Rahim's token can never be used to charge on Karim's account.

Stripe clients kept separate

One reusable connection per API key. Two tenants never share one.

Only real keys are read

A field named amount or currency can never be mistaken for a credential. Only the documented key names are ever picked up.

Secrets hidden from logs

Printing a config object shows [redacted] instead of a live key, so tenant secrets don't leak into your logging service.

Your responsibilities, not the library's.

Encrypt tenant credentials at rest. Never log them. Check that the logged-in user really owns the tenant before charging on their behalf — the library will faithfully use whatever keys you hand it, and cannot tell a legitimate lookup from a mixed-up one.

How keys are found

Three places, checked in order. The first one that has a key wins — which is what lets a tenant override your defaults.

1

Passed into the call Multi-tenant

charge({ gateway: 'stripe', apiKey: 'sk_...' }) — beats everything below.

2

Set with configure() Single company

Your app-wide defaults, set once at startup.

3

Environment variables Single company

STRIPE_API_KEY and friends. The fallback when nothing else is set.

So a platform can keep its own keys as the default and still let individual tenants bring theirs:

// Platform default, set once at startup
configure({ stripe: { apiKey: process.env.PLATFORM_STRIPE_KEY } });

// Tenants without their own account use the platform's
await charge({ gateway: 'stripe', amount: 1000, currency: 'usd' });

// Tenants with their own account override it, just for this call
await charge({ gateway: 'stripe', apiKey: tenant.stripeKey,
               amount: 1000, currency: 'usd' });

Only credential names are read from a call. Recognised names — apiKey, storeId, storePassword, appKey, appSecret, username, password, merchantId, publicKey, privateKey, sandbox, timeoutMs — plus clientIp and baseUrl for Nagad. Everything else is treated as payment data.

If a required key is missing you get told exactly which one, before any network call:

ConfigurationError: Missing required credentials for "bkash": appSecret, password.
Provide them via configure(), per-call options, or environment variables.

The payment journey

What actually happens between "customer clicks Pay" and "order marked paid".

Redirect gateways — SSLCommerz & Nagad

The customer leaves your site to pay, then comes back.

Customer
Clicks Pay

On your checkout page

Your server
charge()

Gets back a payment link

Gateway
Customer pays

On the gateway's own page

Gateway
Sends them back

Plus a callback to your server

Your server
retrieve()

Confirm, then mark paid

Step 5 is not optional. A returning browser proves nothing — a dishonest customer can open your success page without paying a taka. Only what the gateway tells your server directly, via retrieve(), counts as payment.

bKash — approve, then finish

bKash splits it into two calls: you create the payment, the customer approves it in the bKash app, then you complete it.

Your server
charge()

Returns bkashURL

Customer
Approves

Enters their bKash PIN

Your server
execute()

Money actually moves here

You
Save trxID

Needed for refunds

Skip execute() and no money moves — the payment simply expires. If you also want to refund it later, you must store the trxID it returns.

Stripe — one step

With a saved card, a single charge() completes the payment. No redirect, no second call.

The four gateways

Each one loads only when you actually use it, so unused gateways cost you nothing.

Stripe

International cards. Best for customers paying from outside Bangladesh.

API key Cards

SSLCommerz

Bangladesh's biggest aggregator — cards, mobile wallets and net banking in one checkout.

Store ID Redirect

bKash

The most widely used mobile wallet in Bangladesh. Two-step tokenized checkout.

Token Wallet

Nagad

Government-backed mobile wallet. Uses an encrypted key pair rather than a password.

RSA keys Wallet

What each one can do

Gateway Charge Execute Refund Retrieve Credentials needed
Stripe apiKey
SSLCommerz storeId, storePassword
bKash appKey, appSecret, username, password
Nagad merchantId, publicKey, privateKey

You can also ask at runtime:

import { getGatewayCapabilities } from 'bdpayments';

await getGatewayCapabilities('bkash');
// { charge: true, execute: true, refund: true, retrieve: true }

Charge — asking for money

Same function, four gateways. Only the extra fields differ. Add credentials to any of these if you're multi-tenant.

const payment = await charge({
  gateway: 'stripe',
  amount: 2000,          // 2000 = $20.00 — always the smallest unit (cents)
  currency: 'usd',
  paymentMethod: 'pm_card_visa',
  confirm: true,
  description: 'Order #1234',
  metadata: { orderId: '1234' },
});

payment.status; // "succeeded"

Stripe counts in the smallest unit, so 2000 means $20.00. Sending 20 would charge 20 cents. It must be a whole number.

Execute — finishing a bKash payment

Only bKash needs this. It is the step where money actually moves.

import { execute } from 'bdpayments';

const result = await execute({
  gateway: 'bkash',
  paymentID: 'TR0011...',   // from charge()
});

result.status;  // "Completed"
result.trxID;   // save this — you need it to refund

Calling execute() on a gateway that doesn't support it fails clearly with UNSUPPORTED_OPERATION rather than doing something unexpected.

Refund — giving money back

import { refund } from 'bdpayments';

// Full refund
await refund({ gateway: 'stripe', transactionId: 'pi_...' });

// Partial refund — $5.00 of a larger payment
await refund({ gateway: 'stripe', transactionId: 'pi_...', amount: 500 });

// bKash needs both IDs: the payment's and the transaction's
await refund({
  gateway: 'bkash',
  transactionId: 'TR0011...',  // paymentID from charge()
  trxID: 'BGH7DS8...',         // trxID from execute()
  amount: 200,
  reason: 'Customer requested',
});

Always check result.success. Some gateways accept a refund request and process it later. In that case you get success: false with the real state in result.status (for example "processing") — the money has not moved yet.

A refund the gateway rejected throws an error instead, so it can never be mistaken for a successful one.

Retrieve — checking what really happened

The only trustworthy answer to "did they pay?".

import { retrieve } from 'bdpayments';

const details = await retrieve({
  gateway: 'stripe',
  transactionId: 'pi_...',
});

details.success;   // true
details.status;    // "succeeded"
details.amount;    // 1000

Validating an SSLCommerz return

When SSLCommerz sends the customer back, it includes a val_id. Hand that to retrieve() and it checks directly with SSLCommerz's servers.

const validation = await retrieve({
  gateway: 'sslcommerz',
  valId: req.body.val_id,   // from the callback or IPN
});

if (validation.success) {
  await markOrderPaid(validation.transactionId);
}

When the network hiccups

The scariest moment in payments: you asked for money, and never heard back. Did it go through?

The problem. Your request reached the gateway, the customer was charged, and then the reply got lost. Retry blindly and you charge them twice.

The fix is an idempotency key — a label on the request meaning "this is the same one as before, don't do it twice".

Gateway What acts as the key What to do
Stripe idempotencyKey Pass it yourself
SSLCommerz transactionId Reuse the same one when retrying
bKash invoiceNumber Reuse the same one when retrying
Nagad orderId Reuse the same one when retrying
await charge({
  gateway: 'stripe',
  amount: 1000,
  currency: 'usd',
  idempotencyKey: `order-${order.id}`,   // same key = charged once
});

Timeouts

Requests give up after 30 seconds instead of hanging forever. Adjust per gateway if you need to:

configure({ stripe: { apiKey: '...', timeoutMs: 10000 } });

A timeout throws TIMEOUT — but the payment may still have succeeded. Don't retry blindly. Call retrieve() to find out what really happened, or retry with the same idempotency key.

Webhooks & IPN

The gateway phoning your server directly to report a payment. More reliable than the customer's browser — but you must check the call is genuine.

import { verifySslcommerzIpn, retrieve } from 'bdpayments';

app.post('/ipn/sslcommerz', async (req, res) => {
  // 1. Is this really from SSLCommerz?
  if (!verifySslcommerzIpn(req.body)) return res.sendStatus(400);

  // 2. Confirm the status with SSLCommerz itself
  const payment = await retrieve({
    gateway: 'sslcommerz',
    valId: req.body.val_id,
  });

  // 3. Only now is it safe to fulfil the order
  if (payment.success) await markOrderPaid(payment.transactionId);

  res.sendStatus(200);
});

Nagad callbacks carry no signature. parseNagadCallback() tidies one up for you, but always reports verified: false — that is honest, not a bug. Confirm with retrieve() before trusting it.

import { parseNagadCallback, retrieve } from 'bdpayments';

const cb = parseNagadCallback(req.query);
// { orderId, paymentRefId, status, amount, verified: false, raw }

const payment = await retrieve({
  gateway: 'nagad',
  transactionId: cb.paymentRefId,
});

When things go wrong

Every failure throws a PaymentError with a code you can branch on — never a vague string.

import { charge, PaymentError, GatewayNotFoundError, ConfigurationError }
  from 'bdpayments';

try {
  await charge({ gateway: 'stripe', amount: 1000, currency: 'usd' });
} catch (error) {
  if (error instanceof ConfigurationError) {
    console.log('Missing keys:', error.missingKeys);      // ["apiKey"]
  } else if (error instanceof GatewayNotFoundError) {
    console.log('Try one of:', error.supportedGateways);
  } else if (error instanceof PaymentError) {
    console.log(error.gateway);  // "stripe"
    console.log(error.code);     // "CHARGE_FAILED"
    console.log(error.status);   // HTTP status, or null
  }
}
Code What happened What to do
INVALID_REQUEST A required field is missing Fix your code — nothing was sent
INVALID_AMOUNT Amount isn't a positive number Fix your code — nothing was sent
MISSING_CREDENTIALS Keys not found in any of the three places Check error.missingKeys
MISSING_DEPENDENCY npm install stripe was skipped Install it
INVALID_CONFIG Unknown gateway name in configure() Check the spelling
GATEWAY_NOT_FOUND Unknown gateway name in a call Check error.supportedGateways
UNSUPPORTED_OPERATION e.g. execute() on Stripe Use getGatewayCapabilities()
AUTH_FAILED The gateway rejected your keys Check the keys and sandbox flag
CHARGE_FAILED etc. The gateway refused the operation Read error.message
TIMEOUT No reply in time Don't blindly retry — use retrieve()
NETWORK_ERROR Couldn't reach the gateway at all Safe to retry

Before you take real money

Keys never in your code

Environment variables or an encrypted database. Never committed to git.

Always confirm server-side

A returning browser is not proof. retrieve() is.

Trust your own prices

Work out the amount on your server from your database — never from what the browser sent.

Sandbox first

Test every path — success, failure, cancel, refund — before switching sandbox off.

Nagad on certain Node.js versions. Node 18.19.1, 20.11.1 and 21.6.2 disabled a decryption method Nagad depends on. On those exact versions Nagad payments fail with a message saying so. Upgrade to a newer patch release.

API reference

Function Description Returns
configure(configs) Set app-wide credentials for one or more gateways. void
clearConfig() Forget all stored credentials and cached sessions. void
charge(options) Ask a customer for money. Promise<PaymentResult>
execute(options) Finish an approved bKash payment. Promise<PaymentResult>
refund(options) Return money to a customer. Promise<RefundResult>
retrieve(options) Check a payment's real status. Promise<RetrieveResult>
getSupportedGateways() List gateway names. string[]
getGatewayCapabilities(name) Which actions a gateway supports. Promise<GatewayCapabilities>
verifySslcommerzIpn(payload) Check an SSLCommerz IPN is genuine. boolean
parseNagadCallback(query) Tidy a Nagad callback (always unverified). NagadCallback

Environment variables

The lowest-priority way to supply credentials — ideal for single-company setups.

Gateway Variables
Stripe STRIPE_API_KEY
SSLCommerz SSLCOMMERZ_STORE_ID, SSLCOMMERZ_STORE_PASSWORD, SSLCOMMERZ_SANDBOX
bKash BKASH_APP_KEY, BKASH_APP_SECRET, BKASH_USERNAME, BKASH_PASSWORD, BKASH_SANDBOX
Nagad NAGAD_MERCHANT_ID, NAGAD_PUBLIC_KEY, NAGAD_PRIVATE_KEY, NAGAD_SANDBOX, NAGAD_CLIENT_IP

The *_SANDBOX variables accept true or 1 for test mode. Anything else — including leaving them unset — means live mode with real money.

TypeScript

Types ship with the package. Options are narrowed by gateway, so fields belonging to another gateway are rejected as you type.

import { charge, execute, type PaymentResult } from 'bdpayments';

const result: PaymentResult = await charge({
  gateway: 'stripe',
  amount: 1000,
  currency: 'usd',
  paymentMethod: 'pm_card_visa',
});

await charge({
  gateway: 'stripe',
  amount: 1000,
  currency: 'usd',
  invoiceNumber: 'INV-1',   // ✗ Error: bKash-only field
});

await charge({
  gateway: 'bkash',
  amount: 500,              // ✗ Error: invoiceNumber is required
});

await execute({ gateway: 'bkash', paymentID: 'TR0011...' });   // ✓

Anything the types don't model goes through extra, forwarded to the gateway untouched.