# BDPayGate for Node.js: full documentation Source: https://bdpaygate.github.io/node/ · Package: bdpaygate (npm) · Repository: https://github.com/byteminus/bdpaygate-js # BDPayGate: Bangladesh payment gateways for Node.js Website and docs: **https://bdpaygate.github.io/node/** Every Bangladeshi payment gateway behind one typed API: **bKash, Nagad, SSLCommerz, Upay, ShurjoPay, aamarPay, PortPos, PayStation, UddoktaPay, Moneybag, EPS and DGePay**. Rocket, OK Wallet and IBBL/CellFin are covered through SSLCommerz, ShurjoPay and aamarPay. Works with **Express, Next.js, NestJS, Fastify, Hono, Koa, Nuxt, SvelteKit, React Router / Remix, Astro and AdonisJS**, and runs on **Node.js 20.19+, Bun, Deno and Cloudflare Workers**. Zero runtime dependencies. ESM and CommonJS. Written in strict TypeScript. ```ts import { createBdPayGate } from 'bdpaygate'; export const pay = createBdPayGate({ baseUrl: 'https://shop.com.bd', gateways: { bkash: { appKey: '…', appSecret: '…', username: '…', password: '…', sandbox: true }, }, store, // where payments are saved: SQL, Prisma, MongoDB or your own }); const payment = await pay.charge({ gateway: 'bkash', amount: 1500, reference: 'ORD-1001', customer: { name: 'Rahim Uddin', phone: '01711000000', email: 'rahim@example.com' }, }); redirect(payment.checkoutUrl); pay.on('payment.completed', async ({ payment }) => { await orders.markPaid(payment.reference, payment.gatewayTransactionId); }); ``` That is the whole integration. The package owns the callback URLs, confirms every payment with the gateway's own API before trusting it, stores it, fires an event once, and sends the customer back to your app. ## Why this one - **No callback is trusted.** Redirects, IPNs and webhooks are claims. Every gateway confirms the payment server-to-server and checks the amount and currency before anything changes. A forged "failed" or "cancelled" can't stick either: if the gateway later confirms the money moved, the payment completes. - **Exactly once.** Status changes are a single conditional write, so an IPN and a redirect arriving together can't both fire `payment.completed`. - **A timeout is not a failure.** When a gateway doesn't answer, the payment stays pending and `syncPending()` settles it later. Customers who paid are never marked failed. - **Retries are safe.** Each retry is a new attempt with a fresh gateway id; earlier attempts are kept, and if a customer pays two of them you get `payment.duplicated` instead of a second fulfilment. - **One conformance suite for every gateway.** Forged callbacks, amount mismatches, replays, races, timeouts, late successes, malformed input, sync and refunds are tested the same way for all of them. - **Same table as the Laravel package.** A Laravel app and a Node app can share one `bdpaygate_payments` table, and the same `.env` variable names work in both. - **Edge-ready.** Gateways that need only `fetch` (most of them) run anywhere; Nagad, EPS, DGePay and the signature checks load `node:crypto` lazily, which Bun, Deno and Workers (with `nodejs_compat`) provide. ## Gateways | Gateway | Status | Real sandbox run (Node) | Refunds | Sync | Docs | |---|---|---|---|---|---| | SSLCommerz (cards, banks, wallets incl. Rocket, OK Wallet, CellFin) | Stable | ✅ paid via Rocket, sync, partial refund | Full and partial | Yes | [sslcommerz](https://bdpaygate.github.io/node/docs/sslcommerz.md) | | bKash (Tokenized V2, v1.2 supported) | Stable | ✅ paid, two partial refunds, refund status, forged callbacks rejected | Full and partial (up to 10) | Yes | [bkash](https://bdpaygate.github.io/node/docs/bkash.md) | | ShurjoPay | Stable | ✅ paid, declined | Merchant panel | Yes | [shurjopay](https://bdpaygate.github.io/node/docs/shurjopay.md) | | aamarPay | Stable | ✅ paid via Nagad | Merchant panel | Yes | [aamarpay](https://bdpaygate.github.io/node/docs/aamarpay.md) | | PayStation | Stable | ✅ paid via bKash | Merchant panel | Yes | [paystation](https://bdpaygate.github.io/node/docs/paystation.md) | | UddoktaPay (self-hosted) | Stable | ✅ paid, forged invoice rejected | Merchant panel | Yes | [uddoktapay](https://bdpaygate.github.io/node/docs/uddoktapay.md) | | Moneybag | Stable | ✅ paid via Upay | Merchant panel | Via transaction id | [moneybag](https://bdpaygate.github.io/node/docs/moneybag.md) | | Nagad | Beta | Needs merchant sandbox keys | Merchant panel | Yes | [nagad](https://bdpaygate.github.io/node/docs/nagad.md) | | Upay | Beta | Needs merchant sandbox keys | Full | Yes | [upay](https://bdpaygate.github.io/node/docs/upay.md) | | PortPos (formerly PortWallet) | Beta | Needs merchant sandbox keys | Full and partial | Yes | [portpos](https://bdpaygate.github.io/node/docs/portpos.md) | | EPS | Experimental | Needs docs and keys | Merchant panel | Yes | [eps](https://bdpaygate.github.io/node/docs/eps.md) | | DGePay | Experimental | Needs docs and keys | Merchant panel | Yes | [dgepay](https://bdpaygate.github.io/node/docs/dgepay.md) | "Real sandbox run" means a payment was made in the gateway's own sandbox through an Express app using the published package and a SQLite store, and settled by it (2026-10-15). For every gateway with a status API, an unpaid payment was also synced and correctly stayed pending. **Stable**: checked against the gateway's documentation and paid end to end in its sandbox. **Beta**: built from public information; not yet run against a merchant sandbox. **Experimental**: the gateway's docs are only available on request. "Merchant panel" means the gateway publishes no refund API. Test with your own sandbox account before going live. ## Install ```bash npm install bdpaygate # or: pnpm add bdpaygate · yarn add bdpaygate · bun add bdpaygate · deno add npm:bdpaygate ``` ## Configure ```ts // lib/payments.ts import { createBdPayGate, gatewaysFromEnv } from 'bdpaygate'; import { sqlStore, fromPg } from 'bdpaygate/stores/sql'; import { redisCache } from 'bdpaygate/cache/redis'; export const pay = createBdPayGate({ baseUrl: process.env.APP_URL, // public URL the gateways call back to gateways: gatewaysFromEnv(process.env), // BKASH_APP_KEY, SSLCOMMERZ_STORE_ID, … (same names as Laravel) default: 'sslcommerz', store: sqlStore({ client: fromPg(pgPool), dialect: 'postgres' }), cache: redisCache(redis), // shared tokens and locks when you run several processes redirects: { success: '/checkout/thanks', // ?payment={id} is appended failed: '/checkout/failed', cancelled: '/cart', pending: '/checkout/processing', }, }); ``` Or configure gateways in code. The object key is the gateway's name; set `driver` to run two stores of one gateway side by side: ```ts gateways: { sslcommerz_retail: { driver: 'sslcommerz', storeId: '…', storePassword: '…', sandbox: false }, sslcommerz_b2b: { driver: 'sslcommerz', storeId: '…', storePassword: '…', sandbox: false }, } ``` `sandbox` defaults to `true` for every gateway: set it to `false` explicitly for live payments. ## Mount the callback routes Gateways call `/bdpaygate/{gateway}/{success|fail|cancel|ipn|callback}`. Mount the handler **before** any CSRF, session or auth middleware: several gateways return the customer with a cross-site POST. **Express** (4 and 5, with or without body parsers) ```ts import { bdpaygate } from 'bdpaygate/express'; app.use(bdpaygate(pay)); ``` **Next.js App Router**: `app/bdpaygate/[gateway]/[outcome]/route.ts` ```ts import { routeHandlers } from 'bdpaygate/next'; import { pay } from '@/lib/payments'; export const { GET, POST } = routeHandlers(pay); ``` **NestJS** (Express or Fastify platform) ```ts import { bdpaygateMiddleware } from 'bdpaygate/nestjs'; app.use(bdpaygateMiddleware(pay)); // in main.ts, before app.listen() ``` **Fastify** ```ts import { bdpaygate } from 'bdpaygate/fastify'; await app.register(bdpaygate, { pay }); ``` **Hono** (any runtime) ```ts import { bdpaygate } from 'bdpaygate/hono'; app.all('/bdpaygate/:gateway/:outcome', bdpaygate(pay)); ``` **Koa** ```ts import { bdpaygate } from 'bdpaygate/koa'; app.use(bdpaygate(pay)); ``` **Nuxt / Nitro**: `server/routes/bdpaygate/[gateway]/[outcome].ts` ```ts import { eventHandler } from 'bdpaygate/nuxt'; export default defineEventHandler(eventHandler(pay)); ``` **SvelteKit**: `src/routes/bdpaygate/[gateway]/[outcome]/+server.ts` ```ts import { requestHandlers } from 'bdpaygate/sveltekit'; export const { GET, POST } = requestHandlers(pay); ``` SvelteKit rejects cross-site form POSTs by default. Add your gateways' checkout origins to `kit.csrf.trustedOrigins` in `svelte.config.js` (for example `https://sandbox.sslcommerz.com` and `https://securepay.sslcommerz.com`). **React Router 7 / Remix**: `app/routes/bdpaygate.$gateway.$outcome.ts` ```ts import { routeModule } from 'bdpaygate/react-router'; export const { loader, action } = routeModule(pay); ``` **Astro**: `src/pages/bdpaygate/[gateway]/[outcome].ts` ```ts import { endpoints } from 'bdpaygate/astro'; export const prerender = false; export const { GET, POST } = endpoints(pay); ``` Astro's `security.checkOrigin` rejects cross-site form POSTs; allow your gateways' origins or disable it for these routes. **AdonisJS 6** ```ts import { routeHandler } from 'bdpaygate/adonis'; router.any('/bdpaygate/:gateway/:outcome', routeHandler(pay)); // config/shield.ts: csrf.exceptRoutes: (ctx) => ctx.request.url().startsWith('/bdpaygate/') ``` **Bun, Deno, Cloudflare Workers** or anything with Fetch API handlers: ```ts Bun.serve({ fetch: pay.fetch }); Deno.serve(pay.fetch); export default { fetch: pay.fetch }; // Workers ``` **Node's http module, Connect, Polka**: `http.createServer(nodeHandler(pay))` from `bdpaygate/node`. ## Take a payment ```ts const payment = await pay.charge({ gateway: 'sslcommerz', // optional: the default gateway otherwise amount: 1500, // number or string, normalised to "1500.00" reference: order.number, // your id, 1 to 64 characters customer: { name, phone, email, address, city, postcode, ip: req.ip }, // ShurjoPay requires the IP description: `Order ${order.number}`, meta: { orderId: order.id }, // anything JSON, stored with the payment }); return redirect(payment.checkoutUrl); ``` Charging the same reference again creates a new attempt (a retry). A reference that is already paid is refused with `GatewayError`. ## Fulfil orders from events, never from the redirect ```ts pay.on('payment.completed', async ({ payment }) => { if (payment.requiresReview) { // The gateway flagged it as risky (e.g. SSLCommerz risk_level=1): hold delivery. } await orders.markPaid(payment.meta?.orderId, payment.gatewayTransactionId); }); pay.on('payment.failed', ({ payment }) => { /* payment.failureReason */ }); pay.on('payment.cancelled', ({ payment }) => { /* … */ }); pay.on('payment.duplicated', ({ payment }) => { /* a second attempt was also paid: refund one */ }); pay.on('refund.processed', ({ payment, refund }) => { /* … */ }); ``` Listeners are awaited. If one throws, the callback answers with an error so the gateway retries its IPN, but the payment is already saved and the event will not fire again: make listeners idempotent, or put the work on a queue. The customer lands on your `redirects.success` page with `?payment={id}`. Show the status there (`await pay.find(id)`); do not change order state there. ## Settle payments whose callback never arrived ```ts // every 10 minutes: node-cron, a queue, a Vercel/Cloudflare cron, or a protected route const { settled, errors } = await pay.syncPending(); ``` `pay.sync(payment)` asks the gateway about one payment. ## Refunds ```ts const gateway = pay.for(payment); if (gateway.capabilities.refunds) { const refund = await pay.refund(payment, 500, 'Damaged item'); // omit the amount to refund everything left // refund.accepted, refund.pending (settles later), refund.refundId } ``` Refund APIs exist for bKash, SSLCommerz, PortPos (partial) and Upay (full). The rest throw `UnsupportedOperationError`: refund from the gateway's merchant panel. ## Storage | Store | Import | |---|---| | PostgreSQL, MySQL/MariaDB, SQLite via `pg`, `mysql2`, `better-sqlite3` or `node:sqlite` (Node 22.5+) | `sqlStore` from `bdpaygate/stores/sql` (`fromPg`, `fromMysql`, `fromSqlite`) | | Prisma | `prismaStore(prisma.bdpaygatePayment)` from `bdpaygate/stores/prisma`, model in `node_modules/bdpaygate/prisma/bdpaygate.prisma` | | MongoDB | `mongoStore(db.collection('bdpaygate_payments'))` from `bdpaygate/stores/mongodb` | | Memory (tests only) | `memoryStore()` from `bdpaygate` | | Anything else | implement the 7-method `PaymentStore` interface | Create the SQL table with `createTableSql(dialect)` or the files in `node_modules/bdpaygate/sql/`. It is the same table the Laravel package migrates. ## Testing your app ```ts const fake = pay.fake(); // the fake gateway becomes the default const payment = await pay.charge({ amount: 300, reference: 'ORD-1', customer }); await pay.fetch(new Request(payment.checkoutUrl!)); // completes it like a real redirect await fake.fail(payment); // or drive it from code ``` The fake gateway's callbacks refuse to run when `NODE_ENV=production`. ## IP-whitelisted gateways and proxies Nagad and Upay only accept calls from whitelisted server IPs. Run on a fixed-IP server, or pass a `fetch` that goes through a proxy: ```ts import { fetch, ProxyAgent } from 'undici'; const dispatcher = new ProxyAgent(process.env.FIXED_IP_PROXY!); gateways: { nagad: { /* … */, fetch: (url, init) => fetch(url, { ...init, dispatcher }) } } ``` ## Custom gateways ```ts import { AbstractGateway } from 'bdpaygate'; class MyGateway extends AbstractGateway { static override readonly driver = 'mygateway'; async charge(options) { const payment = await this.createPendingPayment(options); /* … */ } async handleCallback(request, outcome) { /* confirm with the API, then this.transition(…) */ } } pay.extend('mygateway', (config, name, ctx) => new MyGateway(config, name, ctx)); ``` ## AI assistants This README and the full docs are published for AI tools at https://bdpaygate.github.io/llms.txt. The key rules for generated code: fulfil orders only in a `payment.completed` listener, never write your own callback routes, keep CSRF and sessions off `/bdpaygate/*`, and use `pay.fake()` in tests. ## Also for Laravel The same gateways, safety rules and database table: [byteminus/laravel-bdpaygate](https://github.com/byteminus/laravel-bdpaygate). ## Security Report vulnerabilities privately: see [SECURITY.md](https://github.com/byteminus/bdpaygate-js/blob/main/SECURITY.md). ## License MIT --- # bKash Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/bkash.md **Status:** stable · **Driver:** `bkash` · **Refunds:** full and partial (up to 10 per transaction) · **Sync:** yes bKash Tokenized Checkout, URL based, without agreement: the customer is sent to bKash's page, enters wallet number, OTP and PIN, and comes back to your site. The driver speaks **Tokenized V2** by default, which has been bKash's default documentation since 2026-08-30. Merchants still provisioned on **v1.2.0-beta** set `version: 'v1.2'`. ## Setup ```dotenv BKASH_APP_KEY=your-app-key BKASH_APP_SECRET=your-app-secret BKASH_USERNAME=your-username BKASH_PASSWORD=your-password BKASH_SANDBOX=true BKASH_VERSION=v2 ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts import { createBdPayGate } from 'bdpaygate'; export const pay = createBdPayGate({ baseUrl: process.env.APP_URL, gateways: { 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, version: 'v2', // "v2" or "v1.2" (v1.2.0-beta) mode: '0011', // v1.2 only: URL-based checkout verifySignature: true, // compare the callback signature with the one bKash issued executeOnSync: true, // sync() executes a verified payment whose callback was lost }, }, store, }); ``` `sandbox` defaults to `true`. Set it to `false` for live payments. Hosts are `https://tokenized.sandbox.bka.sh` (sandbox) and `https://tokenized.pay.bka.sh` (live). `baseUrl` overrides the **host only**: the driver adds `/v2/tokenized-checkout/...` or `/v1.2.0-beta/tokenized/checkout/...` itself. Sandbox credentials are issued by bKash (developer.bka.sh). Live credentials come with merchant onboarding. ### Which version am I on? | | V2 (default) | v1.2.0-beta | |---|---|---| | Paths | `/v2/tokenized-checkout/auth/grant-token`, `/auth/refresh-token`, `/payment/create`, `/payment/execute`, `/query/payment` | `/v1.2.0-beta/tokenized/checkout/token/grant`, `/token/refresh`, `/create`, `/execute`, `/payment/status` | | Field casing | `paymentId`, `trxId` | `paymentID`, `trxID` | | Success body | no `statusCode` | `statusCode: "0000"` | | Errors | `externalCode`, `errorMessageEn` | `statusCode`/`statusMessage` or `errorCode`/`errorMessage` | | `mode` on create | not sent | `0011` | | Refunds | `/v2/tokenized-checkout/refund/payment/transaction` | the same V2 endpoint, as bKash's v1.2.0-beta refund pages document | The driver reads both shapes, so switching is a config change. Payments created under one version must be executed and queried under the same version. ## How a payment is verified 1. `pay.charge()` calls Create Payment with this attempt's `gatewayReference` as `merchantInvoiceNumber`, the customer's phone (or your reference) as `payerReference`, and `callbackURL` = `/bdpaygate/bkash/callback`. bKash's `paymentId` is stored in `payment.gatewayPaymentId`; the customer goes to `payment.checkoutUrl` (`bkashURL`). 2. bKash sends the customer back to `/bdpaygate/bkash/callback?paymentID=…&status=success|failure|cancel&signature=…`. 3. The `signature` must match the one bKash put in that outcome's callback URL in the Create Payment response. A mismatch is rejected before any API call. 4. **success** means "execute now". The driver calls Execute Payment once, which is the step that moves the money: - `Completed` with the right `paymentId`, amount and currency: the payment completes and `trxId` goes to `payment.gatewayTransactionId`. - Any error, or a timeout: the driver asks Query Payment. `Completed` completes the payment. A definite decline (for example `2023` Insufficient Balance, `2029` Duplicate for all transactions, wrong PIN, invalid wallet) marks it **failed**, with the code in `payment.failureReason` (`"2023: Insufficient Balance"`). Anything ambiguous (`2056` Invalid Payment State, `2003`, `503`, HTTP 5xx, a timeout) leaves it **pending**. - Completed for a different amount: not completed, `payment.requiresReview` is set, and the response is logged. Look at it by hand. 5. **failure** and **cancel** are claims. The driver asks Query Payment first. Only if bKash does not report `Completed` does the payment become failed or cancelled. Both can still become completed later if bKash confirms payment. 6. A second success callback (refresh, back button, concurrent request) never executes again. bKash forbids reusing a paymentID after any execute attempt, so the driver queries instead. Fulfil orders in a `payment.completed` listener, not on the redirect page: ```ts pay.on('payment.completed', async ({ payment }) => { await orders.markPaid(payment.reference, payment.gatewayTransactionId); }); ``` ### Sync `pay.sync(payment)` (and `pay.syncPending()`) calls Query Payment: - `Completed`: completes. - `Failed` / `Declined` / `Expired`: failed. `Cancelled`: cancelled. - `Initiated`: stays pending. Once the paymentID is more than 24 hours old it can never complete, so it is marked failed with a `failureReason` that says it expired. - `Initiated` with `verificationStatus: Complete`, never executed, and under 24 hours old: the customer authorised but the callback was lost. The driver executes it (turn this off with `executeOnSync: false`). ## Refunds ```ts import type { BkashGateway } from 'bdpaygate'; const result = await pay.refund(payment, 500, 'Damaged item'); result.accepted; // true: bKash refunded it result.refundId; // refundTrxId result.message; // "Completed", or "2072: Refund amount not valid" on rejection const status = await (pay.for(payment) as BkashGateway).refundStatus(payment); // { originalTrxId: 'DIK80PG0SO', refunds: [{ refundTrxId, status: 'Completed', amount: '10.00', completedAt }], raw: {...} } ``` - Sends `paymentId`, `trxId`, `refundAmount`, `sku` (your reference) and `reason` to the V2 refund API. - Up to 10 refunds per transaction, within 60 days. The driver refuses an 11th. - **Timeouts.** If the refund call times out, the result is `accepted: true, pending: true` and it counts against the refundable amount, so it isn't sent twice. bKash also rejects a duplicate within 10 minutes. Call `refundStatus()` to see whether it landed before refunding again. - **Coupons.** When a coupon was applied, bKash's `maxRefundableAmount` is lower than the payment amount (amount 20.00 with a 4.00 coupon refunds at most 16.00). The driver reads it from the execute/query response and refuses a refund above it, rather than letting bKash answer `2072`. - An accepted refund fires `refund.processed` and updates `payment.refundedAmount`. ## Tokens - Grant Token is called once and the `id_token` is cached (locked, so concurrent requests don't each grant) for about 50 minutes of its 60. - After that the stored `refresh_token` (valid 30 days on V2, 28 on v1.2.0-beta) is used. A new grant happens only when there is no refresh token or bKash refuses it. - **bKash allows two grant/refresh calls per hour. A third blocks the merchant for an hour.** The driver counts them and refuses a third locally with `GatewayUnavailableError`. Callbacks then land on your pending page and sync settles them later. - When bKash rejects a token (HTTP 401/403, `2079` Invalid app Token), the driver drops it, gets a fresh one and retries the call **once**. - Tokens live in the `cache` you pass to `createBdPayGate`. The default is an in-memory cache, which is per process and lost on restart. With several processes, servers or serverless instances, pass a shared cache such as `redisCache(redis)` from `bdpaygate/cache/redis`. Otherwise each process grants its own tokens and can hit the hourly limit. Frequent cold starts or restarts with the memory cache have the same effect. ## Limits and quirks - BDT only. `pay.charge()` refuses other currencies before calling bKash. - A paymentID is valid for 24 hours and can be executed **once**, whatever the result. - A customer authorising on bKash's page moves no money. Only Execute does. Pending payments are recovered by sync within those 24 hours. - `payerReference`, `merchantInvoiceNumber`, `sku` and `reason` may not contain `<`, `>` or `&` and are at most 255 characters. The driver strips and trims them. - `2029` (Duplicate for all transactions): the same wallet paying the same amount to you again within a few minutes is refused at execute. It is recorded as failed with the code. - Webhooks (AWS SNS signed) are configured with bKash support and are not handled by this driver. Run `pay.syncPending()` on a schedule instead. ## Sandbox notes - Keep `BKASH_SANDBOX=true` (or `sandbox: true`) with sandbox credentials. Sandbox and live tokens are cached separately. - The token rules apply in the sandbox too. Parallel test runs against the real sandbox can use up the two calls per hour. - To see an ambiguous result, open the callback URL with `status=success` before finishing on bKash's page. Execute then fails (typically `2056` Invalid Payment State), Query says `Initiated`, and the payment stays pending. - Automated tests don't need the sandbox. Use `pay.fake()` for your app's tests, or pass a `fetch` in the gateway config that returns canned responses (see `tests/conformance/bkash.scenario.ts` in this repository for V2 response shapes). ## Not verified against a live account These points come from bKash's documentation but haven't been tested against a live account. The driver accepts every documented variant: - V2 Query Payment documents only `Initiated` and `Completed`. `Failed`, `Declined`, `Cancelled` and `Expired` are handled in case they appear. - Its sample uses `verificationStatus`; v1.2.0-beta's table calls it `userVerificationStatus`. Both are read. - The shape of an expired-token rejection isn't documented. The driver treats HTTP 401/403, `2079`, or a bare `message` mentioning "Unauthorized" or "token" as one. - V2 grant and refresh responses carry `statusCode: "0000"` like v1.2.0-beta. A token response is accepted whenever it has an `id_token` and no error code. - The callback `signature` is documented only for v1.2.0-beta. If your V2 Create Payment response carries no signature, the check is skipped. - Refund Status takes `trxId`, which the V2 page describes ambiguously. The driver sends the original payment's `trxId`. ## Sandbox runs The Laravel package was verified against the real bKash sandbox through a Laravel 13 app: token grant on V2 and v1.2, create, execute after wallet/OTP/PIN, two partial refunds through the V2 refund API, and refund status. A replayed success callback and a forged cancel were both rejected without changing the payment. This Node package was also run end to end in the bKash sandbox on 2026-10-15: - A payment was paid (trxID `DJF90PPUHR`). - Two partial refunds were accepted (`DJF30PPUI5`, `DJF70PPUI9`), and `refundStatus()` listed both. The Refund Status API can take a few seconds to show a new refund. - A refund above the refundable amount was refused. - Replayed and forged callbacks were rejected. - Syncing an unpaid payment left it pending. --- # Nagad Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/nagad.md **Status:** beta · **Driver:** `nagad` · **Refunds:** no API (refund from the merchant portal) · **Sync:** yes > **Beta.** This driver was built from public information and has not yet been verified against a Nagad merchant sandbox, in either the Laravel or the Node package. Get the official integration guide from Nagad during onboarding and check the behaviour described here against it. Please report anything that differs. Nagad's Online Payment API is a hosted checkout. The customer pays with their Nagad wallet (number, OTP, PIN) on Nagad's page. Access is not self-serve: you need a Nagad merchant account, and Nagad's key account team issues sandbox credentials by email. **Regulatory note.** Nagad is operating, but as of 2026 it runs under an interim Bangladesh Bank approval and a Bangladesh Bank appointed administrator. Its licence status after that interim period was not publicly confirmed when this driver was written. That is a business-continuity risk rather than a technical one. If Nagad matters to your checkout, consider an aggregator (ShurjoPay, aamarPay) as a fallback. ## Setup ```dotenv NAGAD_MERCHANT_ID= NAGAD_MERCHANT_NUMBER= NAGAD_PUBLIC_KEY= NAGAD_PRIVATE_KEY= NAGAD_SANDBOX=true NAGAD_BASE_URL= NAGAD_SIGNATURE_ALGORITHM=sha256 NAGAD_VERIFY_RESPONSE_SIGNATURE=false NAGAD_IP= ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { nagad: { merchantId: process.env.NAGAD_MERCHANT_ID, merchantNumber: process.env.NAGAD_MERCHANT_NUMBER, // optional, your 11-digit merchant wallet publicKey: process.env.NAGAD_PUBLIC_KEY, // Nagad's payment gateway public key privateKey: process.env.NAGAD_PRIVATE_KEY, // your merchant private key sandbox: true, baseUrl: process.env.NAGAD_BASE_URL, // use the exact URL from your onboarding email signatureAlgorithm: 'sha256', verifyResponseSignature: false, ip: process.env.NAGAD_IP, // fallback for the X-KM-IP-V4 header // apiVersion: 'v-0.2.0', // clientType: 'PC_WEB', // PC_WEB, MOBILE_WEB, MOBILE_APP, WALLET_WEB_VIEW // locale: 'EN', // EN or BN // fetch: …, // a proxied fetch for a fixed, whitelisted egress IP (see below) }, } ``` `sandbox` defaults to `true`. Set it to `false` for live payments. There is no `NAGAD_PROXY` variable in the Node package: pass a `fetch` instead (see [IP whitelisting](#limits-and-quirks)). ### Keys There are two different keys, and mixing them up is the most common Nagad integration problem: | Key | Config | Used for | |---|---|---| | Nagad's payment gateway **public** key | `publicKey` | Encrypting what you send, and checking Nagad's signatures | | Your merchant **private** key | `privateKey` | Signing what you send, and decrypting Nagad's answers | - In sandbox, Nagad emails you both. In live, you generate your key pair in the Nagad merchant portal and upload the public half there. Every "generate" creates a new pair and invalidates the old one. - Keys can be given as PEM text (PKCS#1 or PKCS#8), as a bare base64 body with the header lines removed, or as a path to a PEM file. PEM text in an environment variable with literal `\n` sequences works too. - Never commit keys. The tests generate throwaway pairs on the fly. - The driver uses `node:crypto`, loaded lazily. It runs on Node, Bun, Deno and Cloudflare Workers with `nodejs_compat`. ### Hosts The sandbox address differs between sources, so set `baseUrl` to whatever your onboarding email says, including the path up to `/api/dfs`. - Default sandbox: `https://sandbox-ssl.mynagad.com/remote-payment-gateway-1.0/api/dfs`. Other addresses seen in the wild include `http://sandbox.mynagad.com:10080/remote-payment-gateway-1.0/api/dfs` and `http://sandbox.mynagad.com:30001/api/dfs`. - Default live: `https://api.mynagad.com/api/dfs`. ### Signature algorithm The algorithm used to sign requests is configurable because public sources disagree. Integrations that work in production sign with RSA-SHA256, and a signature error (`16_0006_058`) is commonly fixed by switching to it, so `sha256` is the default. Set `signatureAlgorithm` to `sha1` only if Nagad tells you to. `SHA256withRSA` and `SHA1withRSA` are accepted as aliases. `verifyResponseSignature` checks the signature Nagad puts on its encrypted answers, using the same algorithm. It is off by default because the algorithm Nagad uses for responses is not publicly confirmed. Turn it on once you have confirmed it in the sandbox. ### Callback URL The package sends its callback URL with every order: ``` https://your-app.com/bdpaygate/nagad/callback ``` In live, Nagad also asks for the callback URL in the merchant portal and whitelists the domain. Register this URL there. ## Take a payment ```ts const payment = await pay.charge({ gateway: 'nagad', amount: 1500, reference: 'ORD-1001', customer: { name: 'Rahim Uddin', phone: '01711000000', ip: clientIp }, // the IP of the request that started checkout meta: { additionalMerchantInfo: { channel: 'web' } }, // optional, sent to Nagad with your reference }); return redirect(payment.checkoutUrl); ``` ## How a payment is verified 1. `pay.charge()` calls **initialize** (`POST /check-out/initialize/{merchantId}/{orderId}`). It sends `merchantId`, `datetime`, `orderId` and a random 40-character `challenge` as JSON encrypted with Nagad's public key and signed with your private key. Nagad's answer is decrypted with your private key and gives a `paymentReferenceId`. That id is saved as `payment.gatewayPaymentId` **before** the next step, because Nagad's verify API only works by this id. 2. `pay.charge()` then calls **complete** (`POST /check-out/complete/{paymentReferenceId}`) with the encrypted `merchantId`, `orderId`, `amount`, `currencyCode` (`050`) and the challenge, plus `merchantCallbackURL` and `additionalMerchantInfo` (your own reference is stored there as `reference`; add more with `meta.additionalMerchantInfo`). Nagad answers with the checkout URL, stored as `payment.checkoutUrl`. 3. After paying, Nagad redirects the customer's browser to `/bdpaygate/nagad/callback` with `order_id`, `payment_ref_id`, `status`, `status_code` and more. **None of this is signed.** The package only uses `order_id` to find the payment. 4. The package calls **verify** (`GET /verify/payment/{paymentRefId}`) with the `paymentReferenceId` it stored, never one from the callback. The answer must carry the same `orderId` and `merchantId`. Then `status` decides: | `status` | Result | |---|---| | `Success` | Completed, once the amount matches (`issuerPaymentRefNo` stored as `payment.gatewayTransactionId`) | | `Cancelled`, `Aborted` | Cancelled | | `Failed`, `Fraud`, `InvalidRequest`, `UnKnownFailed` | Failed | | `OrderInitiated`, `Ready`, `InProgress`, `OtpSent`, `OtpVerified`, `PinGiven`, `PartialCancelled`, anything else | Stays pending | | HTTP 404 with reason `16_0004_999` (no such payment yet) | Stays pending | `status` is used rather than `statusCode`, whose format varies. A failure callback for a payment Nagad reports as paid completes it. Failed and cancelled payments get a `failureReason` such as `"Nagad: Aborted (…)"`. 5. If Nagad cannot be reached, the payment stays pending and the customer goes to your `pending` redirect. Nagad returns `amount` as a string with no fixed format (`"130"`, `"130.00"`). It is compared numerically. The callback is a browser redirect, so it is lost when the customer closes the tab. Run `pay.syncPending()` on a schedule to settle those payments. ## Refunds Nagad publishes no refund API. Refund from the Nagad merchant portal or through Nagad. `pay.refund()` throws `UnsupportedOperationError` for this gateway. If Nagad gives you a refund specification during onboarding, please open an issue. ## Limits and quirks - **IP whitelisting.** In live, Nagad only accepts calls from server IPs it has whitelisted, which especially matters for servers outside Bangladesh. A rejected call typically returns an HTML page saying "The requested URL was rejected". Run on a fixed-IP server, or pass a `fetch` that goes through a proxy with a fixed, whitelisted egress IP: ```ts import { fetch, ProxyAgent } from 'undici'; const dispatcher = new ProxyAgent(process.env.FIXED_IP_PROXY!); gateways: { nagad: { /* … */, fetch: (url, init) => fetch(url, { ...init, dispatcher }) } } ``` Serverless and edge platforms usually have no fixed outbound IP, so they need the proxy. - **Bangladesh time.** Request timestamps are sent in Asia/Dhaka time (`yyyyMMddHHmmss`) whatever your server timezone is. Nagad rejects timestamps outside a short window (`16_0006_076`), so keep the server clock synced with NTP. - **Order ids.** Nagad accepts 5 to 20 letters and digits, and rejects a reused id on the same day (`16_0006_083`). Each attempt gets a fresh 20-character uppercase id (`gatewayReference`), so a retry never reuses one. The driver refuses to send an id outside those rules. - **`X-KM-IP-V4`** must be the customer's IPv4 address. On charge, the driver sends `customer.ip` (or `meta.customerIp`) when it is a public IPv4 address. On the callback it uses the request's IP. Otherwise (IPv6, private addresses, local development, queued jobs and `pay.syncPending()`) it sends `ip` from the config, so set that to your server's public IPv4. - **Small encrypted payload.** RSA-2048 can encrypt at most 245 bytes, so the encrypted JSON holds only the fields Nagad requires. - **Currency** must be `BDT`. - **Error envelope.** Errors come as `{"reason": "16_0006_xxx", "message": "..."}`. Both are included in the `GatewayError` message, and the body is logged on the payment. --- # SSLCommerz Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/sslcommerz.md **Status:** stable · **Driver:** `sslcommerz` · **Refunds:** full and partial (asynchronous) · **Sync:** yes SSLCommerz is a hosted checkout covering cards, internet banking and most mobile wallets. It is also how you accept wallets that have no public merchant API of their own: **Rocket (DBBL)**, **OK Wallet** and **IBBL / CellFin** are offered on the SSLCommerz checkout page. ## Setup ```dotenv SSLCOMMERZ_STORE_ID=yourstore SSLCOMMERZ_STORE_PASSWORD=yourstore@ssl SSLCOMMERZ_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { sslcommerz: { storeId: process.env.SSLCOMMERZ_STORE_ID, storePassword: process.env.SSLCOMMERZ_STORE_PASSWORD, sandbox: true, }, } ``` `sandbox` defaults to `true`. Set it to `false` for live payments. Hosts are `https://sandbox.sslcommerz.com` (sandbox) and `https://securepay.sslcommerz.com` (live). Sandbox stores are self-serve at . Live refunds need your server's public IP registered with SSLCommerz. If your app has no fixed outbound IP, pass a `fetch` that goes through a proxy with one: ```ts import { fetch, ProxyAgent } from 'undici'; const dispatcher = new ProxyAgent(process.env.FIXED_IP_PROXY!); gateways: { sslcommerz: { /* … */, fetch: (url, init) => fetch(url, { ...init, dispatcher }) }, } ``` ## Take a payment ```ts const payment = await pay.charge({ gateway: 'sslcommerz', amount: 1500, reference: 'ORD-1001', customer: { name: 'Rahim Uddin', phone: '01711000000', email: 'rahim@example.com', address: 'Dhanmondi' }, description: 'Order ORD-1001', // sent as product_name meta: { category: 'electronics' }, // meta.category and meta.profile fill product_category and product_profile }); return redirect(payment.checkoutUrl); ``` ## How a payment is verified 1. `pay.charge()` creates a session (`/gwprocess/v4/api.php`) with this attempt's `gatewayReference` as `tran_id`, the payment id in `value_a` and your own reference in `value_b`. 2. SSLCommerz posts the IPN and redirects the customer (`/bdpaygate/sslcommerz/{success|fail|cancel|ipn}`). 3. A success is accepted only if the `verify_sign` signature matches **and** the Order Validation API confirms the `val_id`, the `tran_id` and the amount. For foreign-currency payments, `currency_type` and `currency_amount` are checked rather than the converted `amount`. 4. A fail or cancel is a claim anyone could send: the package asks the Transaction Query API first, and completes the payment if SSLCommerz says it was paid. 5. `risk_level = 1` completes the payment but sets `payment.requiresReview = true`. SSLCommerz asks you to hold the service and verify the customer before delivering. `bank_tran_id` is stored in `payment.gatewayTransactionId`. ```ts pay.on('payment.completed', async ({ payment }) => { if (payment.requiresReview) { // risk_level=1: hold delivery until you have checked the customer. return; } await orders.markPaid(payment.reference, payment.gatewayTransactionId); }); ``` `pay.sync(payment)` and `pay.syncPending()` use the Transaction Query API. A payment SSLCommerz reports as paid completes; otherwise it stays as it is. ## Refunds ```ts import type { SslCommerzGateway } from 'bdpaygate'; const result = await pay.refund(payment, 500, 'Damaged item'); result.accepted; // true: SSLCommerz initiated it result.pending; // true: the bank settles it later result.refundId; // refund_ref_id await (pay.for(payment) as SslCommerzGateway).refundStatus(result.refundId!); // "refunded", "processing" or "cancelled" ``` A `processing` answer to a refund request means that refund was already initiated, so it is not recorded a second time. An accepted refund fires `refund.processed` and updates `payment.refundedAmount`. ## Limits and quirks - Amount must be 10.00 to 500000.00 BDT. Checked before the API call. - `tran_id` is at most 30 characters; attempt ids are 20. - The return to your site is a cross-site POST. Mount the bdpaygate handler **before** any CSRF, session or auth middleware so the POST is accepted and customers are not logged out. Your landing page receives a normal GET. - SvelteKit and Astro reject cross-site form POSTs by default. Add `https://sandbox.sslcommerz.com` and `https://securepay.sslcommerz.com` to SvelteKit's `kit.csrf.trustedOrigins`, or allow them in Astro's `security.checkOrigin` handling. ## Sandbox runs This Node package was run end to end in the SSLCommerz sandbox on 2026-10-15: - A payment was paid via Rocket, and the cross-site POST return was handled. - Sync of the paid payment worked. - A partial refund was accepted asynchronously, and `refundStatus()` reported `"processing"`. - Syncing an unpaid payment left it pending. --- # Upay Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/upay.md **Status:** beta · **Driver:** `upay` · **Refunds:** full amount only, may be asynchronous · **Sync:** yes > **Beta.** This driver was built from public information and has not yet been verified against an Upay merchant sandbox, in either the Laravel or the Node package. Get the official API document from Upay during onboarding and check the behaviour described here against it. Please report anything that differs. Upay is the mobile financial service of UCB Fintech. Its merchant payment gateway is a hosted checkout where the customer pays with their Upay wallet. Access is not self-serve: Upay issues the API document and credentials during merchant onboarding. ## Setup ```dotenv UPAY_MERCHANT_ID= UPAY_MERCHANT_KEY= UPAY_MERCHANT_CODE= UPAY_MERCHANT_NAME= UPAY_MERCHANT_MOBILE= UPAY_MERCHANT_CITY=Dhaka UPAY_MERCHANT_CATEGORY_CODE= UPAY_SANDBOX=true UPAY_BASE_URL= ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { upay: { merchantId: process.env.UPAY_MERCHANT_ID, merchantKey: process.env.UPAY_MERCHANT_KEY, merchantCode: process.env.UPAY_MERCHANT_CODE, merchantName: process.env.UPAY_MERCHANT_NAME, merchantMobile: process.env.UPAY_MERCHANT_MOBILE, merchantCity: 'Dhaka', merchantCategoryCode: process.env.UPAY_MERCHANT_CATEGORY_CODE, sandbox: true, baseUrl: process.env.UPAY_BASE_URL, // merchantCountryCode: 'BD', // tokenTtl: 600, // seconds to cache the auth token // fetch: …, // a proxied fetch for a fixed, whitelisted egress IP (see below) }, } ``` - All of these come from Upay. The merchant code and category code may arrive only with the live credentials. Every merchant field is sent with each payment, and `merchantId`, `merchantKey`, `merchantName`, `merchantCode`, `merchantCategoryCode` and `merchantMobile` are required: `pay.charge()` throws `ConfigurationError` when one is missing. - `sandbox` defaults to `true`. Set it to `false` for live payments. - Hosts: `https://uat-pg.upay.systems` (UAT) and `https://pg.upaysystem.com` (live). The live host is not stated in Upay's document; confirm it with Upay and override with `baseUrl` if it differs. - There is no `UPAY_PROXY` variable in the Node package: pass a `fetch` instead (see [IP whitelisting](#limits-and-quirks)). - Currency must be `BDT`. ## How a payment is verified 1. The driver gets a token from `POST /payment/merchant-auth/` with your merchant id and key, and sends it as `Authorization: UPAY {token}` on every later call. Upay does not document an expiry, so the token is cached for `tokenTtl` seconds (10 minutes by default) in the `cache` you pass to `createBdPayGate`. A 401 or 403 drops the cached token and retries once with a fresh one. 2. `pay.charge()` calls `POST /payment/merchant-payment-init/`. The attempt's `gatewayReference` is sent as **both** `txn_id` and `invoice_id`, because the redirect only carries `invoice_id` and the status API only takes `txn_id`. Your own reference goes in `additional_info.reference` (and your `description`, if any, in `additional_info.description`). Upay's `trx_id` is stored as `payment.gatewayPaymentId`, and the customer is sent to `payment.checkoutUrl` (`gateway_url`). 3. Upay redirects the customer's browser to `/bdpaygate/upay/callback` with `status` and `invoice_id`. **This is not signed.** The package only uses it to find the payment. 4. The package calls `GET /payment/single-payment-status/{txn_id}/`. The answer must carry the same `txn_id`. Then `status` decides: | `status` | Result | |---|---| | `success` | Completed, once the amount matches (`trx_id` stored as `payment.gatewayTransactionId`) | | `failed`, `expired` | Failed | | `cancelled` | Cancelled | | `pending`, anything else | Stays pending | | Not found (`PNF4012`) or an error | Stays pending | A failure redirect for a payment Upay reports as paid completes it. Failed and cancelled payments get a `failureReason` such as `"Upay: expired"`. 5. If Upay cannot be reached, the payment stays pending and the customer goes to your `pending` redirect. `pay.syncPending()` settles it later. ## Refunds ```ts const result = await pay.refund(payment); // full amount only result.accepted; // Upay took it result.pending; // true when Upay answered "under process" result.message; // Upay's English message ``` - Refunds go through Upay's bulk refund endpoint (`POST /payment/bulk/refund/`) with one entry: the payment's `txn_id` and the amount as a string. - A `txn_id` in the answer's success list is an accepted refund. - A failure entry reading "Your request is under process" means Upay took the request and will settle it later. It is reported as accepted with `pending: true`, and counts against the refundable amount. Upay documents no refund status endpoint, so confirm the outcome in the merchant panel. - Any other failure entry (for example "Invalid amount" or "already refunded") is reported as not accepted, with Upay's English message in `result.message`. - **Full refunds only.** The API takes a per-transaction amount, but partial refunds are not publicly confirmed, so `pay.refund(payment, '50.00')` throws `UnsupportedOperationError`. `pay.for(payment).capabilities.refunds` is `'full'`. Make partial refunds from the merchant panel. - If the refund call times out, nothing is recorded. Check the merchant panel before you retry. - An accepted refund fires `refund.processed` and updates `payment.refundedAmount`. ## Limits and quirks - **IP whitelisting.** Upay's live gateway is IPv4 only and expects calls from whitelisted server IPs. Run on a fixed-IP server, or pass a `fetch` that goes through a proxy with a fixed, whitelisted IPv4 egress address: ```ts import { fetch, ProxyAgent } from 'undici'; const dispatcher = new ProxyAgent(process.env.FIXED_IP_PROXY!); gateways: { upay: { /* … */, fetch: (url, init) => fetch(url, { ...init, dispatcher }) } } ``` Serverless and edge platforms usually have no fixed outbound IP, so they need the proxy. - **Trailing slashes.** Every Upay path ends in `/`. Without it, the server may redirect and drop the POST body. - **The redirect URL format.** Upay may append `&status=…&invoice_id=…` to the redirect URL instead of starting a query string. The package's redirect URL already carries a query parameter (`?txn=`), so the appended values still parse, and the callback also handles a second `?`. If `invoice_id` is missing, `txn` identifies the payment. Either way, the status API decides. - **Duplicate `txn_id`s are rejected** (`DTP4003`). Each attempt gets a fresh 20-character id, so a retry never reuses one. - **Bangladesh time.** The init `date` is today's date in Asia/Dhaka, whatever your server timezone is. - **The token carries your merchant key** in readable form. The driver never logs auth responses. If you pass a custom `fetch` that logs requests, keep it away from the token too. - **Amounts** come back as JSON numbers (`150.0`) and are compared numerically. --- # ShurjoPay Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/shurjopay.md **Status:** stable · **Driver:** `shurjopay` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes ShurjoPay is a hosted checkout from a Bangladesh Bank licensed payment system operator. One integration covers: - **Cards:** Visa, Mastercard, American Express, UnionPay, Nexus Pay, Diners. - **Mobile wallets:** bKash, Nagad, **Rocket (DBBL)**, Upay, Tap, mCash, Pathao Pay, Pocket, Meghna Pay, CellFin and others ShurjoPay lists on its checkout. - **EMI** on supported cards. That makes it a way to accept Rocket and the smaller wallets that have no public merchant API of their own. The verification response's `method` field (stored in `payment.gatewayResponse`) tells you which one the customer used. ## Setup ```dotenv SHURJOPAY_USERNAME=sp_sandbox SHURJOPAY_PASSWORD=pyyk97hu&6u6 SHURJOPAY_PREFIX=sp SHURJOPAY_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { shurjopay: { username: process.env.SHURJOPAY_USERNAME, password: process.env.SHURJOPAY_PASSWORD, prefix: process.env.SHURJOPAY_PREFIX, sandbox: true, }, } ``` - `prefix` is the short order prefix (at most 5 characters) agreed with ShurjoPay. ShurjoPay builds its own order id (`sp_order_id`) from it. - The values above are ShurjoPay's public sandbox credentials. Live credentials come from ShurjoPay during merchant onboarding. Sandbox tokens do not work on live. - `sandbox` defaults to `true`. Set it to `false` for live payments. - Hosts: `https://sandbox.shurjopayment.com` (sandbox) and `https://engine.shurjopayment.com` (live). Override with `baseUrl` (host only, without `/api`). - Currency must be `BDT` or `USD`. ### Client IP ShurjoPay requires the customer's IP address (`client_ip`), and the sandbox rejects a payment without one. Pass the IP of the request that started checkout as `customer.ip`: ```ts const payment = await pay.charge({ gateway: 'shurjopay', amount: 1500, reference: 'ORD-1001', customer: { name: 'Rahim Uddin', phone: '01711000000', ip: clientIp }, }); ``` If you give none, the driver sends `127.0.0.1` so the request is not rejected. Behind a proxy or load balancer, take the IP from the header your proxy sets (for example `X-Forwarded-For`), not the socket address. ### IPN ShurjoPay can also notify a server URL about payments whose browser return was lost. Ask ShurjoPay support to register: ``` https://your-app.com/bdpaygate/shurjopay/ipn ``` The IPN is optional: `pay.syncPending()` settles payments whose return never arrived. ## How a payment is verified 1. `pay.charge()` gets an access token (`/api/get_token`), then creates the payment (`/api/secret-pay`). The attempt's `gatewayReference` is sent as `order_id`, your own reference goes in `value1` and the payment id in `value2`. ShurjoPay's `sp_order_id` is stored as `payment.gatewayPaymentId`. 2. ShurjoPay sends the customer back to `/bdpaygate/shurjopay/success` or `/cancel` with only `?order_id=`. The return carries no status and no signature. The IPN carries the same single field. 3. Whatever the URL, the package calls the Verification API (`/api/verification`) with the `sp_order_id`. It checks that the answer is about the same `sp_order_id` and `customer_order_id`, and that the amount and currency match. Only then does `sp_code` decide: | `sp_code` | Meaning | Result | |---|---|---| | `1000` | Paid | Completed (`bank_trx_id` stored as `payment.gatewayTransactionId`) | | `1001` | Declined by the issuing bank | Failed (`payment.failureReason`, e.g. `"1001 Failed"`) | | `1002` | Cancelled by the customer | Cancelled | | `1068` | Initiated, not paid yet (undocumented) | Stays pending | | anything else | Unknown | Stays pending | A cancel return for a payment ShurjoPay reports as paid completes it. A cancel return for a payment that is still "initiated" leaves it pending, because anyone can open that URL. 4. If ShurjoPay cannot be reached, the payment stays pending and the customer goes to your `pending` redirect. `pay.syncPending()` settles it later. These fire `payment.completed`, `payment.failed` or `payment.cancelled` once. ## Refunds ShurjoPay has no public refund API. Refund from the ShurjoPay merchant panel or through ShurjoPay support. `pay.refund()` throws `UnsupportedOperationError` for this gateway, and `pay.for(payment).capabilities.refunds` is `false`. ## Limits and quirks - **Tokens last 15 minutes.** The docs sample says `expires_in: 3600` "milliseconds", but the API returns `900` seconds. The token is cached for at most 14 minutes, together with the `store_id` the token call returns. It is shared across processes when you pass a shared `cache` (for example `redisCache(redis)`) to `createBdPayGate`. - **`1064 Unauthorized`** (undocumented) means the token expired or was revoked. The package fetches a new token and retries once. If ShurjoPay still refuses, the call fails with `GatewayUnavailableError` and the payment stays pending: check the username and password. - **Errors come with HTTP 200.** Verification answers are a one-element JSON array, but errors are sometimes a bare object, and `secret-pay` validation errors are an object of field messages. All of these are handled. - **`sp_code`** is a string in practice and an integer in the docs; both are accepted. - **Verification only knows ShurjoPay's id.** Asking with your own `order_id` returns `1011`. Callbacks are therefore looked up by `gatewayPaymentId`, and a payment whose `secret-pay` call timed out before returning an `sp_order_id` cannot be synced. - **Amounts come back with four decimals** (`"150.0000"`). They are compared after rounding to two. - **`value4` is overwritten by ShurjoPay** in sandbox, so the package does not use it. - **Customer email** is optional at ShurjoPay; when the customer has none, a placeholder is sent. - **Return URLs are GET requests**, and IPNs are POSTs. The package's callback routes accept both. Keep CSRF and session middleware off `/bdpaygate/*`. ## Sandbox Use the public sandbox credentials above with `SHURJOPAY_SANDBOX=true`. The sandbox checkout lives at `sandbox.securepay.shurjopayment.com`. This Node package was run end to end in the ShurjoPay sandbox on 2026-10-15: - A payment was paid. - A declined payment was marked failed, with `failureReason` `"1001 Failed"`. - Syncing an unpaid payment left it pending. - The sandbox rejects payments without `client_ip`, which is why the driver always sends one. --- # aamarPay Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/aamarpay.md **Status:** stable · **Driver:** `aamarpay` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes aamarPay is a hosted checkout from a Bangladesh Bank licensed payment system operator. One integration covers: - **Cards:** Visa, Mastercard, UnionPay, Nexus. - **Mobile wallets:** bKash, Nagad, **Rocket (DBBL)**, Upay, Tap, **OK Wallet**, SureCash, Qcash. - **Net banking.** That makes it a way to accept Rocket, OK Wallet and other wallets that have no public merchant API of their own. aamarPay notes that the card option only shows for amounts of 10 BDT or more. ## Setup ```dotenv AAMARPAY_STORE_ID=aamarpaytest AAMARPAY_SIGNATURE_KEY=dbb74894e82415a2f7ff0ec3a97e4183 AAMARPAY_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { aamarpay: { storeId: process.env.AAMARPAY_STORE_ID, signatureKey: process.env.AAMARPAY_SIGNATURE_KEY, sandbox: true, }, } ``` - The values above are aamarPay's public sandbox credentials. Live credentials come from aamarPay support once your merchant account is set up. - `sandbox` defaults to `true`. Set it to `false` for live payments. - Hosts: `https://sandbox.aamarpay.com` (sandbox) and `https://secure.aamarpay.com` (live). Override with `baseUrl`. Sandbox credentials on the live host (or the reverse) fail with a misleading "Invalid Store ID". - Currency must be `BDT` or `USD`. ### IPN aamarPay can POST a JSON notification for successful payments to a URL you give their technical support during onboarding. Give them: ``` https://your-app.com/bdpaygate/aamarpay/ipn ``` The IPN is optional: `pay.syncPending()` settles payments whose return never arrived. ## How a payment is verified 1. `pay.charge()` posts to `/jsonpost.php`. The attempt's `gatewayReference` is sent as `tran_id`, your own reference in `opt_a` and the payment id in `opt_b`. The session id in the returned `payment_url` (`track=AAM…`) is aamarPay's `pg_txnid`, stored as `payment.gatewayPaymentId`. 2. aamarPay POSTs the customer's browser to `/bdpaygate/aamarpay/success` or `/fail`, with `mer_txnid`, `pg_txnid`, `status_code` and more. The cancel button returns the customer to `/bdpaygate/aamarpay/cancel?tran_id=…` with no payload. None of these are signed. 3. A callback whose `pg_txnid` differs from the stored one is rejected at once. 4. Every callback, cancel and IPN is then confirmed with the Search Transaction API (`/api/v1/trxcheck/request.php`) by `tran_id`. The answer must carry the same `mer_txnid` and `pg_txnid`. Then `status_code` decides: | `status_code` | Meaning | Result | |---|---|---| | `2` | Successful | Completed, once the amount and currency match and `pay_status` is "Successful" (`bank_trxid` stored as `payment.gatewayTransactionId`) | | `7` | Failed | Failed | | `3` | Expired | Failed | | `0` | Initiated, not paid yet | Stays pending | | anything else | Unknown | Stays pending | A fail or cancel for a payment aamarPay reports as paid completes it. 5. If aamarPay cannot be reached, or answers in plain text instead of JSON, the payment stays pending and the customer goes to your `pending` redirect. `pay.syncPending()` settles it later. For BDT payments, `amount` is compared. For USD payments, `amount` is converted to BDT, so `amount_currency` and `currency_merchant` are compared instead. A `risk_level` above 0 in the search result completes the payment but sets `payment.requiresReview = true`. Check it in your `payment.completed` listener before delivering. ## Refunds aamarPay has no public refund API. Refund from the aamarPay merchant panel or through aamarPay support. `pay.refund()` throws `UnsupportedOperationError` for this gateway, and `pay.for(payment).capabilities.refunds` is `false`. ## Limits and quirks - **A reused `tran_id` is accepted.** aamarPay then creates a second session and Search Transaction only reports the newest one. The package sends a fresh attempt id on every charge and cross-checks `pg_txnid`, so an answer about another session never completes a payment. - **Errors come with HTTP 200.** Init errors are `{"result": false, "errors": [...]}` or `{"result": false, "message": "..."}`. Success is `"result": "true"`, a string. A wrong key on search returns plain text ("Store_id & signature key not matched"). All of these are handled. - **Missing values are placeholders.** Fields hold `"Not-Available"` or `"Not Available"` instead of null; the package treats both as empty. - **The signature key travels in the search query string.** If you pass a custom `fetch` that logs requests, redact it. - **The return is a cross-site POST.** aamarPay's own troubleshooting page suggests excluding the callback from CSRF and loosening session cookies. Mount the bdpaygate handler before any CSRF, session or auth middleware and you don't need to: customers are not logged out, and your landing page receives a normal GET. SvelteKit and Astro reject cross-site form POSTs by default; allow aamarPay's checkout origin there. - **`tran_id`** may be at most 32 characters; attempt ids are 20. - **Customer email** is required by aamarPay; when the customer has none, a placeholder is sent. - **The IPN's signature scheme is not documented**, so the IPN is treated like any other claim and confirmed with Search Transaction. ## Sandbox Use the public sandbox credentials above with `AAMARPAY_SANDBOX=true`. aamarPay's own samples note that the sandbox may only offer bKash; the run below was paid with Nagad, so what it offers can vary. This Node package was run end to end in the aamarPay sandbox on 2026-10-15: - A payment was paid via Nagad. - Syncing an unpaid payment left it pending. --- # PortPos (formerly PortWallet) Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/portpos.md **Status:** beta (built from the public v2 docs, not yet run against a sandbox) · **Driver:** `portpos` · **Refunds:** full and partial, up to 5 per invoice (asynchronous) · **Sync:** yes PortPos is Portonics' hosted invoice checkout: local and international cards, mobile wallets, net banking and EMI. **PortWallet rebranded to PortPos**: `portwallet.com` and `developer.portwallet.com` redirect to `portpos.com`, and the old API hosts (`api.portwallet.com`, `api-sandbox.portwallet.com`) no longer work. This driver only talks to `*.portpos.com`. Many older SDKs and plugins still hard-code the dead hosts. Neither the Laravel nor the Node package has been run against a PortPos sandbox yet. ## Setup ```dotenv PORTPOS_APP_KEY=your-app-key PORTPOS_SECRET_KEY=your-secret-key PORTPOS_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { portpos: { appKey: process.env.PORTPOS_APP_KEY, secretKey: process.env.PORTPOS_SECRET_KEY, sandbox: true, // validity: 3600, // optional: invoice lifetime in seconds (60 to 31536000) // zipcode: '1000', // optional: billing zipcode used when the customer has no postcode }, } ``` `validity` and `zipcode` have no environment variables; set them in code. `sandbox` defaults to `true`. Set it to `false` for live payments. | | Sandbox | Live | |---|---|---| | API | `https://api-sandbox.portpos.com/payment/v2/` | `https://api.portpos.com/payment/v2/` | | Payment page | `https://ppv2.portpos.com/v2/invoice/{invoice_id}` | `https://payment.portpos.com/v2/invoice/{invoice_id}` | | Merchant panel | `merchant-sandbox.portpos.com` | `merchant.portpos.com` | App keys are generated in the PortPos merchant panel, and sandbox and live keys can be active at the same time. It is not clear whether the sandbox panel lets you sign up yourself; ask PortPos for a sandbox merchant account if it does not. Docs: . PortPos only accepts TLS 1.2. The auth token embeds the current Unix time, so keep the server clock NTP-synced. ## How a payment is verified 1. `pay.charge()` creates an invoice (`POST /payment/v2/invoice`) with this attempt's `gatewayReference` as `order.reference`. The invoice id is stored in `payment.gatewayPaymentId`, and the customer is sent to `payment.checkoutUrl` (the `action.url` PortPos returns). 2. Every call is authenticated with `Authorization: Bearer base64(appKey:md5(secretKey + unix_time))`, built fresh for each request. 3. PortPos sends the customer back to `/bdpaygate/portpos/callback?ref=…` and posts an IPN (`invoice`, `amount`, `status`, `reference`) to `/bdpaygate/portpos/ipn?ref=…`, for successes **and** failures. Neither is signed. 4. Every return, IPN and sync is confirmed with the IPN validate API, `GET /payment/v2/invoice/ipn/{invoice_id}/{amount}`. It is always called with the invoice id and amount **stored on the payment**, never the ones in the request. The returned `order.amount` and `order.currency` are checked as well. 5. The returned `order.status` decides the outcome: - `ACCEPTED`, `REFUND_PENDING` or `REFUND_PROCESSING` → completed. This holds even if the request claimed a failure. - `CANCELLED` → cancelled. - `EXPIRED` → failed. - `PENDING`, any other value, or an error answer → stays pending. A failed card attempt usually leaves the invoice payable. 6. PortPos has no transaction id. The processor's `billing.gateway.approve_code` is stored in `payment.gatewayTransactionId`. You can call the validate API yourself with `(pay.for(payment) as PortPosGateway).validateInvoice(payment)`; it returns PortPos's raw answer without changing the payment. ## Refunds ```ts import type { PortPosGateway } from 'bdpaygate'; const result = await pay.refund(payment, 500); // partial; omit the amount to refund the rest result.accepted; // true: PortPos approved the request result.pending; // true: processed asynchronously result.refundId; // null: PortPos returns no refund id await (pay.for(payment) as PortPosGateway).refundStatus(payment); // the invoice state: "refund_pending", "refund_processing", "accepted", ... ``` PortPos allows up to 5 refunds per invoice, and in total no more than the invoice amount. The v2 API takes no refund reason, so a reason passed to `pay.refund()` is not sent. There is no per-refund status endpoint; `refundStatus()` reads the invoice's `order.status`. An accepted refund fires `refund.processed` and updates `payment.refundedAmount`. ## Limits and quirks - Required customer fields, as PortPos validates them: - **Email.** The driver falls back to `noemail@example.com` when the customer has none. - **Phone**, 9 to 15 characters. - **Street.** `customer.address`, or the city when there is none. - **City.** `customer.city`, default `Dhaka`. - **State.** `customer.state`, else `meta.state`, else the city. - **Zipcode**, 3 to 8 characters. `customer.postcode`, else `meta.zipcode`, else the config `zipcode`, else `1000`. - **Country**, as an ISO 3166 alpha-2 or alpha-3 code. `Bangladesh` (the default) is sent as `BD`. Any other country must already be given as a code, otherwise `pay.charge()` throws before calling PortPos. - Product name and description must be at least 3 characters; the driver uses `Order {reference}` when your description is shorter. - `result` is `success` on reads and `SUCCESS` on refunds. Errors come back as `{"result":"ERROR","error":{"cause","explanation","property"}}` with a 4xx status, and the explanation is used in `GatewayError` messages. - The auth token uses MD5 from `node:crypto`, loaded lazily. It runs on Node, Bun, Deno and Cloudflare Workers with `nodejs_compat`. - Several things are **unverified until checked against a sandbox**: - The full `order.status` vocabulary. The docs list PENDING, CANCELLED, EXPIRED, ACCEPTED, REFUND_PENDING and REFUND_PROCESSING. - What the IPN validate API returns when the amount does not match. - Whether the path amount must be `150.00` or `150`. The driver sends two decimals. - The query parameters PortPos adds to the return URL. The driver needs none of them. --- # PayStation Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/paystation.md **Status:** stable · **Driver:** `paystation` · **Refunds:** no API (refund from the merchant panel) · **Sync:** yes PayStation (Service Hub Ltd) is a hosted checkout covering cards (Visa, Mastercard) and the bKash, Nagad, Rocket and Upay wallets. The request and response shapes below were checked against PayStation's documentation and its public sandbox, but not against a live merchant account. See [Not yet verified](#not-yet-verified). ## Setup ```dotenv PAYSTATION_MERCHANT_ID=104-1234567890 PAYSTATION_PASSWORD=your-store-password PAYSTATION_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { paystation: { merchantId: process.env.PAYSTATION_MERCHANT_ID, password: process.env.PAYSTATION_PASSWORD, sandbox: true, // payWithCharge: true, // sent as pay_with_charge=1, see below }, } ``` `payWithCharge` has no environment variable; set it in code. `sandbox` defaults to `true`. Set it to `false` for live payments. PayStation issues separate merchant IDs for sandbox and live. Register at ; the docs are at . | Environment | API host | |---|---| | Sandbox | `https://sandbox.paystation.com.bd` | | Live | `https://api.paystation.com.bd` | ## How a payment is verified 1. `pay.charge()` calls `POST /initiate-payment` (form fields) with `merchantId`, `password`, this attempt's `gatewayReference` as `invoice_number`, the amount as `payment_amount` (for example `"150.00"`), and your own reference in `reference`. The package's payment id goes in `opt_a`. 2. PayStation answers with `status_code: "200"` and a `payment_url`. Any other `status_code` is an error, even though the HTTP status is 200 (for example `1008` "Duplicate invoice number.", `1001` "Invalid Credential."). The package throws `GatewayError` with PayStation's message and code. 3. The customer comes back to `/bdpaygate/paystation/callback?status=Successful|Failed|Canceled&invoice_number=…&trx_id=…`. These query parameters are unsigned, so the package ignores everything except `invoice_number` (and uses `status` only to tell a cancel from a failure). 4. The package asks `POST /transaction-status` (header `merchantId`, body `invoice_number`) and acts on `data.trx_status`: | `trx_status` (any casing) | Result | |---|---| | `success` or `successful` | Completed, once `invoice_number` and the amount match. `trx_id` is stored as `payment.gatewayTransactionId`. | | `failed` | Failed, or cancelled if the customer's callback said `Canceled`. | | `processing` | Stays pending (the customer has not finished paying). | | `refund` | Stays pending, and the response is logged in `payment.gatewayResponse` for you to review. | | any `status_code` other than `"200"`, or a non-JSON body | Stays pending. `pay.syncPending()` asks again later. | A failed or cancelled payment still becomes completed if a later status check says `success`. ### Amount check The package compares `request_amount` (or `payment_amount` when `request_amount` is missing) with the order amount. If `payWithCharge` is off, `payment_amount` must also match exactly. Turn `payWithCharge` on only if your PayStation account adds the processing charge to what the customer pays. The amount actually paid is then allowed to be higher than the order amount. ## Refunds PayStation documents no refund API, so `pay.for(payment).capabilities.refunds` is `false` and `pay.refund()` throws `UnsupportedOperationError`. Refund from the PayStation merchant panel. ## Limits and quirks - Business errors come back as **HTTP 200** with a string `status_code`. The driver never treats HTTP 200 alone as success. - `trx_status` casing is inconsistent: the field description says `success`, but the samples show `Success` and `Failed`. The driver lowercases and trims it before comparing. - The status endpoint is authenticated by the `merchantId` header alone, with no password and no token. That is how PayStation documents it. Treat what it returns as non-secret. - `payment_amount` is documented as an integer, but the sandbox accepts and echoes two decimal places, and the driver always sends two decimals. - `cust_email` is mandatory. If the customer has no email, the driver sends `noemail@example.com`. - PayStation's own PHP sample turns off TLS certificate checks. This package never does. - The public docs site sits behind Cloudflare and may block automated fetches. Read it in a browser. ## Not yet verified - **IPN / webhook.** PayStation's current documentation describes only the browser redirect to `callback_url`. Any server-to-server notification is configured per merchant and its payload is undocumented. If PayStation sends one to `/bdpaygate/paystation/ipn` with an `invoice_number`, it is verified through `/transaction-status` exactly like the redirect. Run `pay.syncPending()` on a schedule either way: ```ts // every 10 minutes: node-cron, a queue, a Vercel/Cloudflare cron, or a protected route const { settled, errors } = await pay.syncPending(); ``` - **`payWithCharge` semantics.** The documentation says only "Will the merchant bear the payment charge or not". Confirm with PayStation which direction `1` means for your account. - **Live behaviour.** The shapes come from the documentation and the sandbox, not from a live account. - **"Invalid Token."** One documented error mentions a token, which suggests an older token-based API exists. This driver uses only the documented `merchantId` and password endpoints. - **Status v2.** `POST /v2/transaction-status` (by PayStation `trxId`) exists but is not used. Lookups are always by our own `invoice_number`. ## Sandbox runs The Laravel package was verified against the real PayStation sandbox through a Laravel 13 app: a 120 BDT payment paid with bKash inside PayStation's checkout, confirmed by `POST /transaction-status`. The live sandbox reports `trx_status: "successful"`, not the documented `"success"`; the driver accepts both. No refund API and no separate IPN exist (checked against the current docs): the callback to `callback_url` is the only notification, and it is never trusted on its own. This Node package was also run end to end in the PayStation sandbox on 2026-10-15: - A payment was paid via bKash. - Syncing an unpaid payment left it pending. --- # UddoktaPay Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/uddoktapay.md **Status:** stable · **Driver:** `uddoktapay` · **Refunds:** no (see below) · **Sync:** yes, once the customer has submitted a payment > [!WARNING] > **UddoktaPay is not a licensed payment gateway.** It is self-hosted software that you install on your own server. Customers pay straight into mobile-wallet or bank accounts that you own, often personal bKash, Nagad, Rocket or Upay accounts, and the software then detects those payments. There is no acquirer, no payment service provider and no settlement in between. > > Collecting business payments through personal wallet accounts may breach your wallet provider's terms and Bangladesh Bank rules. You alone are responsible for your accounts, KYC, tax and regulatory compliance, disputes and refunds. This package only talks to the API your installation exposes and makes no claim that using it is permitted. If you need a licensed gateway, use SSLCommerz, bKash, Nagad, PayStation or another regulated provider. ## Setup ```dotenv UDDOKTAPAY_BASE_URL=https://pay.yourdomain.com UDDOKTAPAY_API_KEY=your-api-key UDDOKTAPAY_SANDBOX=false ``` These are read by `gatewaysFromEnv(process.env)`. For UddoktaPay only, `UDDOKTAPAY_SANDBOX` defaults to `false` when it is not set, because the software is self-hosted. Or configure the gateway in code: ```ts gateways: { uddoktapay: { baseUrl: process.env.UDDOKTAPAY_BASE_URL, apiKey: process.env.UDDOKTAPAY_API_KEY, sandbox: false, // set it explicitly: in code, sandbox defaults to true for every gateway }, } ``` - `baseUrl` is **required** for live use. Use your UddoktaPay installation's own domain. You can paste the panel URL or a full API URL: anything from `/api` onwards is stripped, so `https://pay.yourdomain.com/api/checkout-v2` also works. - `apiKey` comes from your UddoktaPay dashboard. It is sent in the `RT-UDDOKTAPAY-API-KEY` header. - With `sandbox` on and no `baseUrl`, the driver uses UddoktaPay's public sandbox, `https://sandbox.uddoktapay.com`. Its shared test API key is published on UddoktaPay's [API information](https://uddoktapay.readme.io/reference/api-information) page. With `sandbox` off and no `baseUrl`, `pay.charge()` throws. - Only BDT is supported, through the `checkout-v2` API. UddoktaPay's `checkout-v2/global` variant for other currencies is not wired in. Docs: ## How a payment is verified UddoktaPay creates its `invoice_id` only after the customer submits a payment on the checkout page. Before that, the payment has no id that UddoktaPay can be asked about. 1. `pay.charge()` calls `POST {baseUrl}/api/checkout-v2` with the customer's name and email, the amount as a string, and `metadata` holding this attempt's `gatewayReference`, your own reference and the package's payment id. It also sends: - `redirect_url`: `/bdpaygate/uddoktapay/success`, with `return_type: GET` - `cancel_url`: `/bdpaygate/uddoktapay/cancel?ref=…` - `webhook_url`: `/bdpaygate/uddoktapay/ipn` The customer is sent to `payment.checkoutUrl` (the returned `payment_url`). 2. On success, UddoktaPay returns the customer with an `invoice_id`. It arrives as a query parameter, or as a POST field if your installation posts it; both are accepted. 3. The package calls `POST {baseUrl}/api/verify-payment` with that `invoice_id`. It finds the payment **from the `metadata` in the verified response**, never from the callback. - **`COMPLETED`:** completes the payment once the amount matches. `invoice_id` is stored as `payment.gatewayPaymentId` and `transaction_id` as `payment.gatewayTransactionId`. - **`PENDING`:** the customer submitted a payment that has not been matched yet, for example while you approve it manually. The payment stays pending and the `invoice_id` is kept, so `pay.sync(payment)` and `pay.syncPending()` can settle it later. - **`ERROR`** (for example "No Data Found"): the callback is rejected and the payment is unchanged. 4. **Webhook (`/ipn`):** UddoktaPay's only webhook check is that the `RT-UDDOKTAPAY-API-KEY` header equals your API key. There is no signature, timestamp or replay protection. - A missing or wrong key is rejected with HTTP 400 before any API call. - A correct key is still not trusted: the posted `invoice_id` goes through `verify-payment` exactly like a redirect, and the webhook body is ignored. 5. **Cancel:** UddoktaPay's cancel link is your `cancel_url` as given, with no `invoice_id`, and verify-payment cannot report a failed or cancelled state. So a cancel cannot be confirmed: - If the payment already has an `invoice_id`, the package verifies it and completes the payment when UddoktaPay says it is paid. - Otherwise the payment stays pending and the customer is sent to your `pending` redirect. Show a "try again" option there. `pay.charge()` with the same reference creates a fresh attempt. Because of this, a UddoktaPay payment never fires `payment.failed` or `payment.cancelled`. It either completes or stays pending. ## Refunds Not supported. UddoktaPay documents `POST /api/refund-payment`, but both its success and error responses are documented as an empty object `{}`. There is no way to tell whether a refund was accepted. Money also sits in your own wallet accounts, so refund it from there. `pay.refund()` throws `UnsupportedOperationError`. ## Limits and quirks - `amount` is sent as a string with two decimals. - `email` is mandatory. If the customer has none, the driver sends `noemail@example.com`. - The amount check uses `amount` from verify-payment, the invoice amount. `charged_amount` can differ when your installation adds a fee. - `pay.sync()` on a payment with no `invoice_id` returns it unchanged without calling UddoktaPay. - UddoktaPay's documentation says `redirect_url` is used to build the success, failure and cancel URLs, but does not show the exact form. This driver keeps `redirect_url` free of query parameters so an appended `?invoice_id=` cannot break it. - The webhook is documented as an "IPN callback for manual data submission from the admin dashboard". Do not rely on it arriving for every payment. Run `pay.syncPending()` on a schedule: ```ts // every 10 minutes: node-cron, a queue, a Vercel/Cloudflare cron, or a protected route const { settled, errors } = await pay.syncPending(); ``` ## Sandbox The public sandbox (`https://sandbox.uddoktapay.com`, shared API key on the API information page) accepts `checkout-v2` and `verify-payment` calls. Its checkout page asks the customer to type in a wallet transaction id. Because the sandbox is shared, other people's test invoices live there too. Use it only to try the flow, never for anything real. This Node package was run end to end in the UddoktaPay sandbox on 2026-10-15: - A payment was paid via bKash. - A forged invoice id was rejected, and so was a webhook without the API key. - Syncing an unpaid payment left it pending. ## Local testing note The UddoktaPay sandbox drops a custom port from the return URL (`http://127.0.0.1:8123/...` comes back as `http://127.0.0.1/...`). This was seen in both the Laravel and the Node sandbox runs. When testing locally, use a URL without a custom port: a tunnel, or port 80/443. Production URLs are unaffected. --- # Moneybag Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/moneybag.md **Status:** stable · **Driver:** `moneybag` · **Refunds:** no public API · **Sync:** no public API (see below) Moneybag is a Bangladeshi hosted checkout (cards, mobile wallets, EMI). Its public merchant API is small: create a checkout, and verify a transaction. ## Setup ```dotenv MONEYBAG_API_KEY=your-merchant-api-key MONEYBAG_SANDBOX=true # MONEYBAG_BASE_URL=https://api.moneybag.com.bd # optional: override the API origin ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { moneybag: { apiKey: process.env.MONEYBAG_API_KEY, sandbox: true, // baseUrl: process.env.MONEYBAG_BASE_URL, // override the API origin }, } ``` `sandbox` defaults to `true`. Set it to `false` for live payments. | | API origin | |---|---| | Sandbox | `https://sandbox.api.moneybag.com.bd` | | Live | `https://api.moneybag.com.bd` | Sandbox accounts are self-serve: create one at (or ). The merchant API key is shown **once**, so store it straight away. Production keys need a separate review and approval by Moneybag. The public contract lists only the sandbox origin; the live origin above comes from Moneybag's own SDK and plugin releases. Confirm it with Moneybag at go-live, and set `baseUrl` if they give you another. Docs: . ## How a payment is verified 1. `pay.charge()` creates a checkout (`POST /api/v2/payments/checkout`, header `X-Merchant-API-Key`). This attempt's `gatewayReference` is sent as `order_id`, and your own reference goes in `metadata.reference`. The returned `session_id` is stored in `payment.gatewayPaymentId`, and the customer is sent to `payment.checkoutUrl`. 2. Moneybag returns the customer to `/bdpaygate/moneybag/{success|fail|cancel}?ref=…`, adding `transaction_id` and `status` (`SUCCESS`, `FAILED` or `CANCELLED`). 3. Neither parameter is trusted. The driver calls `GET /api/v2/payments/verify/{transaction_id}` and checks: - that the transaction's `order_id` is this attempt's `gatewayReference`; - for a payment, that `status` is `SUCCESS` **and** `verified` is `true`, and that `amount` and `currency` match. 4. A verified `SUCCESS` completes the payment, even on the fail or cancel URL. A verified `FAILED` or `CANCELLED` fails or cancels it. Anything else stays pending: an unknown transaction, a non-success answer, or `verified: false`. 5. The Moneybag `transaction_id` is stored in `payment.gatewayTransactionId`. ## No sync, no refunds The public API can only verify by `transaction_id`. There is no lookup by order or session, and no refund API: Moneybag lists refunds, payouts and transaction history as merchant-portal features outside the public API. So: - `pay.for(payment).capabilities` is `{ sync: false, refunds: false }`. `pay.sync()` and `pay.refund()` throw `UnsupportedOperationError`, and `pay.syncPending()` skips Moneybag payments. Refund from the Moneybag merchant portal. - If a customer pays but never comes back, find the transaction id in the merchant portal and settle the payment yourself: ```ts import type { MoneybagGateway } from 'bdpaygate'; const updated = await (pay.for(payment) as MoneybagGateway).verifyTransaction(payment, 'txn…'); ``` This runs the same checks as a return, and fires the usual events. - If the customer cancels before a transaction exists, the return has no `transaction_id`. The payment stays pending, and the customer lands on your `pending` redirect. ## Limits and quirks - Amount must be 10.00 to 1000000.00 BDT. Checked before the API call. - `order_id` must be 10 to 36 characters; attempt ids are 20. - `customer.email` is required by Moneybag; the driver falls back to `noemail@example.com`. Pass `customer.postcode` (or `meta.postcode`) to send a postcode. - Validation errors are HTTP 422 with `{"detail":[{"loc","msg","type"}]}`; they are included in the `GatewayError` message. - **Webhooks are not handled yet.** Moneybag signs webhooks (`X-Webhook-Signature: sha256=HMAC(secret, "{timestamp}.{raw body}")`), but the payment event body is not in the public contract, and webhook endpoints are configured in the merchant portal. The driver sends no `ipn_url`. - Still **unverified**: the non-terminal `status` values returned by verify. Only `SUCCESS`, `FAILED` and `CANCELLED` are documented; anything else leaves the payment pending. ## Hosts (checked 2026-10-15) Both `https://sandbox.api.moneybag.com.bd` (this driver's sandbox default) and `https://staging.api.moneybag.com.bd` (the official SDK's default) are live and require authentication. `https://api.moneybag.com.bd` is production. Override any of them with `baseUrl`. A merchant API key is issued from the Moneybag panel; there is no public demo key. ## Sandbox runs The Laravel package was verified against the real Moneybag sandbox through a Laravel 13 app with a self-serve sandbox account (developer portal → sandbox onboarding issues a merchant ID and a one-time-display API key). A 120 BDT payment paid with bKash completed after `GET /api/v2/payments/verify/{transaction_id}`; a simulated failure was confirmed as `FAILED` and marked failed. The verify response reports the order amount in `amount` and Moneybag's service charge separately in `charge_amount` (the customer saw 121.20), so the amount check correctly compares `amount`. This Node package was also run end to end in the Moneybag sandbox on 2026-10-15: - A payment was paid via Upay. Moneybag showed its service charge separately, and the order amount was the one checked. - There is no status API, so no sync was run. --- # EPS (Easy Payment System) Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/eps.md **Status:** experimental · **Driver:** `eps` · **Refunds:** no (use the EPS merchant panel) · **Sync:** yes EPS is a hosted checkout covering cards (Visa, Mastercard, Amex), internet banking and most wallets (bKash, Nagad, Rocket, OK Wallet and others). > **Experimental.** EPS does not publish its API reference. This driver is built from the provider's published SDKs and has not been run against an EPS sandbox, in either the Laravel or the Node package. Request the integration guide and sandbox credentials from EPS (integration@eps.com.bd), and verify the driver against the sandbox before going live. ## Setup ```dotenv EPS_USERNAME= EPS_PASSWORD= EPS_HASH_KEY= EPS_MERCHANT_ID= EPS_STORE_ID= EPS_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { eps: { username: process.env.EPS_USERNAME, password: process.env.EPS_PASSWORD, hashKey: process.env.EPS_HASH_KEY, merchantId: process.env.EPS_MERCHANT_ID, storeId: process.env.EPS_STORE_ID, sandbox: true, // Optional: // deviceTypeId: '…', // sent only if set // statusAttempts: 3, // status API tries when EPS answers 404 // statusRetryDelay: 1500, // milliseconds between those tries // ipnWindow: 120, // minutes of pending payments an IPN re-checks // ipnSweepLimit: 10, // at most this many per IPN // tokenTtl: 300, // seconds, if EPS gives no token expiry }, } ``` The optional settings have no environment variables; set them in code. `sandbox` defaults to `true`. Set it to `false` for live payments. All five credentials come from EPS after merchant registration and KYC. There are no public test credentials. | Environment | API host | |---|---| | Sandbox | `https://sandboxpgapi.eps.com.bd` | | Live | `https://pgapi.eps.com.bd` | ## Capabilities | | | |---|---| | Charge (hosted checkout) | yes | | Callback verification | status API, every time | | Sync (`pay.sync`, `pay.syncPending`) | yes | | Refunds | no. EPS's refund policy says refunds can be requested "through the API or by email", but no refund endpoint has been published. `pay.refund()` throws `UnsupportedOperationError`. | | Currency | BDT only | ## Take a payment ```ts const payment = await pay.charge({ gateway: 'eps', amount: 1500, reference: 'ORD-1001', customer: { name: 'Rahim Uddin', phone: '01711000000', email: 'rahim@example.com', ip: clientIp }, meta: { state: 'Dhaka', postcode: '1205' }, // optional: CustomerState and CustomerPostcode }); return redirect(payment.checkoutUrl); ``` `customer.ip` is sent as `ipAddress` (`0.0.0.0` when you give none). `meta.category` and `meta.profile` fill `ProductCategory` and `ProductProfile`. `CustomerPostcode` comes from `meta.postcode` (default `1000`), not from `customer.postcode`. ## How a payment is verified 1. `pay.charge()` fetches a token (`/v1/Auth/GetToken`), caches it until it expires, and calls `/v1/EPSEngine/InitializeEPS`. - `merchantTransactionId` is this attempt's `gatewayReference`: 20 digits, new for every attempt. - `CustomerOrderId` is your reference plus a per-attempt suffix. - `ValueA` and `ValueB` carry the payment id and your reference. - EPS's `TransactionId` is stored in `payment.gatewayPaymentId`. 2. Every request carries `x-hash` = base64(HMAC-SHA512(hash key, value)). The signed value depends on the endpoint: - GetToken signs the username. - InitializeEPS signs the merchant transaction id. - CheckMerchantTransactionStatus signs the merchant transaction id. 3. EPS returns the customer with a GET to `/bdpaygate/eps/{success|fail|cancel}`, adding `?Status=…&MerchantTransactionId=…&EPSTransactionId=…`. The redirect is not signed, so the package ignores `Status` and calls `CheckMerchantTransactionStatus` to get the result. 4. A payment is completed only when the status API says it was paid **and** `TotalAmount` matches. EPS's transaction id goes into `payment.gatewayPaymentId` and `payment.gatewayTransactionId`. 5. If the status API says failed or cancelled, the payment is marked that way. If it says pending, gives an unknown status, or has no answer, the payment stays pending. `pay.syncPending()` settles it later. ### The 404 after payment Right after a payment, EPS's status API can answer 404 for a short time. The driver retries 404s (`statusAttempts`, `statusRetryDelay`). If it still gets 404 after that, the payment stays **pending**. It is never marked failed because of a 404. An HTTP 5xx answer is treated as "EPS unavailable": the payment stays pending too. ### IPN EPS's IPN body is encrypted with AES-256-CBC (`{"Data": ":"}`), and the key format is not documented, so the driver does not decrypt it. It treats any POST to `/bdpaygate/eps/ipn` as a signal: - If the body names the merchant transaction id in plain text, that payment is checked. - Otherwise the driver checks this gateway's pending payments from the last `ipnWindow` minutes, newest first, up to `ipnSweepLimit` of them, against the status API. Because the IPN URL is public and unauthenticated, this sweep runs at most once a minute and skips the 404 retries. It uses the `cache` you pass to `createBdPayGate`, so with several processes use a shared cache (for example `redisCache(redis)`) for the limit to hold across them. - With no recent pending EPS payment, the IPN is rejected with HTTP 400. An IPN body is never treated as proof of payment. Ask EPS support to register `https://your-app/bdpaygate/eps/ipn` as your IPN URL. ## Limits and quirks - **Errors come back with HTTP 200.** For example, a bad login gives `token: null` and an `errorMessage`. The driver reads the body, not the status code. - **Mixed key casing and literal `null`s.** Requests mix camelCase and PascalCase. Responses are read case-insensitively, and `null` or `""` counts as missing. - **Escaped return parameters.** EPS can HTML-escape the separators in the return URL (`&MerchantTransactionId=`). The driver strips the `amp;` prefix. The callback URLs it gives EPS have no query string. - **The status API is GET.** POST was reported to return 405. - **Rate limit.** EPS was seen to send `X-Rate-Limit-Limit: 40`. The time window is not known. Keep `ipnSweepLimit` small. - **Origin/IP checks.** EPS may only accept calls from your registered domain or IP. If your servers have no fixed IP, pass a `fetch` that goes through a proxy with one: ```ts import { fetch, ProxyAgent } from 'undici'; const dispatcher = new ProxyAgent(process.env.FIXED_IP_PROXY!); gateways: { eps: { /* … */, fetch: (url, init) => fetch(url, { ...init, dispatcher }) } } ``` - **`node:crypto`.** The HMAC and the 20-digit ids use `node:crypto`, loaded lazily. It runs on Node, Bun, Deno and Cloudflare Workers with `nodejs_compat`. ## Unverified These points come from EPS's SDKs and a production write-up, not from an official specification: - The status vocabulary. The driver accepts `Success`, `Successful`, `Succeeded`, `Completed`, `Complete` and `Paid` as paid; `Failed`, `Failure`, `Fail`, `Declined` and `Rejected` as failed; `Cancel`, `Canceled`, `Cancelled`, `Aborted` and `Abort` as cancelled. Anything else is pending. - Whether `deviceTypeId` and `transactionDate` are required. `transactionDate` is always sent; `deviceTypeId` only when configured. - Limits on the format and length of `merchantTransactionId`. EPS's SDKs use digits only, so the driver does too. - How long a token lasts. The driver uses `expireDate` or the JWT `exp` claim, and falls back to `tokenTtl`. - How IPNs are registered, how often they are retried, and what reply EPS expects. EPS's IPN page asks for `{"status":"OK"}`, but the package answers `{"ok":true,...}`. - Whether there is a refund API. --- # DGePay Also for Laravel: https://github.com/byteminus/laravel-bdpaygate/blob/main/docs/gateways/dgepay.md **Status:** experimental · **Driver:** `dgepay` · **Refunds:** no (use the DGePay merchant panel) · **Sync:** yes DGePay is a Bangladesh Bank-licensed payment service operator. Its hosted checkout covers bKash, Nagad and other mobile wallets. > **Experimental.** DGePay's API guide is only given to merchants. This driver is built from the provider's published SDK and has not been run against DGePay's UAT, in either the Laravel or the Node package. Request the integration guide and UAT credentials from DGePay, and verify the driver against UAT before going live. ## Setup ```dotenv DGEPAY_CLIENT_ID= DGEPAY_CLIENT_SECRET= DGEPAY_API_KEY= DGEPAY_SANDBOX=true ``` These are read by `gatewaysFromEnv(process.env)`. Or configure the gateway in code: ```ts gateways: { dgepay: { clientId: process.env.DGEPAY_CLIENT_ID, clientSecret: process.env.DGEPAY_CLIENT_SECRET, apiKey: process.env.DGEPAY_API_KEY, sandbox: true, // true = UAT // Optional: // baseUrl: 'https://api-dev.dgepay.net/dipon/v3', // DGePay's DEV environment // failedStatusCodes: ['4'], // status codes treated as a definite failure // tokenTtl: 300, // seconds, if the JWT has no exp claim }, } ``` The optional settings have no environment variables; set them in code. `sandbox` defaults to `true`. Set it to `false` for live payments. | Environment | API base | |---|---| | UAT (`sandbox: true`) | `https://api-uat.dgepay.net/dipon/v3` | | Live | `https://apiv2.dgepay.net/dipon/v3` | | DEV (set as `baseUrl`) | `https://api-dev.dgepay.net/dipon/v3` | ## Capabilities | | | |---|---| | Charge (hosted checkout) | yes | | Callback verification | status API, every time | | Sync (`pay.sync`, `pay.syncPending`) | yes | | Refunds | no. No refund endpoint has been published. `pay.refund()` throws `UnsupportedOperationError`. | | Server-to-server notification | none known. Run `pay.syncPending()` on a schedule. | | Currency | BDT only | ## How a payment is verified 1. **Authenticate.** `POST /payment_gateway/authenticate` with `Authorization: Basic base64(clientId:clientSecret)`, plus the same two values in the JSON body. The JWT that comes back is cached until its `exp` claim, less a minute, in the `cache` you pass to `createBdPayGate`. If DGePay answers 401, the driver fetches a new token and retries once. 2. **Initiate.** `POST /payment_gateway/initiate_payment` sends `amount`, `unique_txn_id`, `redirect_url` and `note` (your description, or your reference). It also sends `customer_token` and `payment_method` (null unless you pass `meta.paymentMethod`), and `payee_information` when `customer.phone` is a Bangladeshi mobile number. - `unique_txn_id` is this attempt's `gatewayReference`, new for every attempt. - The body is **encrypted**: JSON with sorted keys → AES-128-ECB (PKCS#7) → base64. The key is the client secret, padded with `0` or cut to 16 bytes. - The `Signature` header is base64(HMAC-SHA256(api key, canonical)). *canonical* is the same sorted JSON with `{ } " : ,` and spaces removed, so `{"amount":150.0,"note":"Order 1"}` becomes `amount150.0noteOrder1`. Nulls appear as `null`, and amounts as `150.0` or `150.5`. - The customer is redirected to `payment.checkoutUrl` (`data.webview_url`). 3. **Return.** DGePay returns the customer to `/bdpaygate/dgepay/callback?data=`. - Query-string parsing turns `+` into spaces, so the driver restores the `+` signs, then decrypts. A `data` value that does not decrypt with your client secret is rejected. - Only `unique_txn_id` is used from the decrypted payload, to find the payment. Its `status_code` is ignored. 4. **Confirm.** `POST /payment_gateway/check_transaction_status` (encrypted and signed in the same way) gives the result: | `data.status_code` | Result | |---|---| | `3` | Completed, if `data.amount` matches | | `8` | Cancelled | | `4` (`failedStatusCodes`) | Failed | | anything else, or no `data` | stays pending | On completion, `txn_id` goes into `payment.gatewayPaymentId` and `txn_number` into `payment.gatewayTransactionId`. An HTTP 5xx answer is treated as "DGePay unavailable": the payment stays pending. ## Limits and quirks - **Errors come back with HTTP 200.** The body has `"success": 0` and an `error` list. The driver reads the body. - **Plain JSON is rejected.** DGePay answers it with `REQUESTED_PARAMETERS_ARE_MISMATCHED`. Every call after authentication must be encrypted and signed. - **AES-ECB is DGePay's choice.** It is a weak mode, used here only because the API requires it. Keep the client secret and API key out of version control. - **Signature formatting is strict.** Numeric-looking strings (phone numbers, `+88`) are signed as given. Only real numbers are rendered as JSON numbers. - **`node:crypto`.** The encryption and signature use `node:crypto`, loaded lazily. It runs on Node, Bun, Deno and Cloudflare Workers with `nodejs_compat`. ## Unverified These points come from DGePay's published SDK, not from the request-only API guide: - The full status-code table. `3` (success) and `8` (cancelled) are documented. `4` comes from an SDK sample whose message is "TRANSACTION FAILED". Every other code is treated as pending. - How amounts with two decimal places (`150.25`) appear in the signature. The driver uses the JSON form, `150.25`. One unofficial client rounds to one decimal place instead. - Whether `authenticate` also needs the body credentials. The SDK sends Basic auth only. This driver sends both. - Whether `payee_information` and `payment_method` are validated. - How long a token lasts, beyond the JWT `exp` claim. - Whether DGePay has a webhook/IPN or a refund API. None is known. - How to get UAT or DEV credentials. --- # Contributing Thanks for helping. Bug reports with a failing test are the most useful thing you can send. ## Setup Developing needs Node.js 22.18 or newer (the build tool requires it). The published package runs on Node.js 20.19+, Bun and Deno, and CI tests it on all of them. ```bash git clone https://github.com/byteminus/bdpaygate-js.git cd bdpaygate-js npm install npm test ``` | Command | What it does | |---|---| | `npm test` | Vitest: conformance suite, driver tests, adapters, stores | | `npm run typecheck` | `tsc --noEmit` in strict mode | | `npm run lint` / `npm run format` | Biome | | `npm run build` | ESM + CJS + types with tsdown | | `npm run check:package` | publint and arethetypeswrong on the packed tarball | Run one gateway's conformance tests with `CONFORMANCE=bkash,nagad npx vitest run tests/conformance.test.ts`. ## Adding or changing a gateway 1. The driver lives in `src/drivers/.ts` and extends `AbstractGateway`. 2. Add `tests/conformance/.scenario.ts`: it teaches the shared conformance suite how the gateway answers. A gateway is supported when it passes every conformance test. 3. Put gateway-specific behaviour in `tests/drivers/.test.ts`. 4. Never trust callback data: find the payment by its id, then confirm with the gateway's API and `assertAmountMatches()` before calling `transition()`. 5. A timeout must leave the payment pending (`GatewayUnavailableError`), never failed. Keep the Laravel package (`byteminus/laravel-bdpaygate`) in step: behaviour changes should land in both. ## Pull requests - One topic per pull request, with tests. - `npm run lint && npm run typecheck && npm test` must pass. - Add a line to `CHANGELOG.md`.