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
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:
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
pay.charge()calls Create Payment with this attempt'sgatewayReferenceasmerchantInvoiceNumber, the customer's phone (or your reference) aspayerReference, andcallbackURL=/bdpaygate/bkash/callback. bKash'spaymentIdis stored inpayment.gatewayPaymentId; the customer goes topayment.checkoutUrl(bkashURL).- bKash sends the customer back to
/bdpaygate/bkash/callback?paymentID=…&status=success|failure|cancel&signature=…. - The
signaturemust match the one bKash put in that outcome's callback URL in the Create Payment response. A mismatch is rejected before any API call. - success means "execute now". The driver calls Execute Payment once, which is the step that moves the money:
Completedwith the rightpaymentId, amount and currency: the payment completes andtrxIdgoes topayment.gatewayTransactionId.- Any error, or a timeout: the driver asks Query Payment.
Completedcompletes the payment. A definite decline (for example2023Insufficient Balance,2029Duplicate for all transactions, wrong PIN, invalid wallet) marks it failed, with the code inpayment.failureReason("2023: Insufficient Balance"). Anything ambiguous (2056Invalid Payment State,2003,503, HTTP 5xx, a timeout) leaves it pending. - Completed for a different amount: not completed,
payment.requiresReviewis set, and the response is logged. Look at it by hand.
- failure and cancel are claims. The driver asks Query Payment first. Only if bKash does not report
Completeddoes the payment become failed or cancelled. Both can still become completed later if bKash confirms payment. - 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:
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 afailureReasonthat says it expired.InitiatedwithverificationStatus: Complete, never executed, and under 24 hours old: the customer authorised but the callback was lost. The driver executes it (turn this off withexecuteOnSync: false).
Refunds
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) andreasonto 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: trueand it counts against the refundable amount, so it isn't sent twice. bKash also rejects a duplicate within 10 minutes. CallrefundStatus()to see whether it landed before refunding again. - Coupons. When a coupon was applied, bKash's
maxRefundableAmountis 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 answer2072. - An accepted refund fires
refund.processedand updatespayment.refundedAmount.
Tokens
- Grant Token is called once and the
id_tokenis 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,
2079Invalid app Token), the driver drops it, gets a fresh one and retries the call once. - Tokens live in the
cacheyou pass tocreateBdPayGate. 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 asredisCache(redis)frombdpaygate/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,skuandreasonmay 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(orsandbox: 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=successbefore finishing on bKash's page. Execute then fails (typically2056Invalid Payment State), Query saysInitiated, and the payment stays pending. - Automated tests don't need the sandbox. Use
pay.fake()for your app's tests, or pass afetchin the gateway config that returns canned responses (seetests/conformance/bkash.scenario.tsin 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
InitiatedandCompleted.Failed,Declined,CancelledandExpiredare handled in case they appear. - Its sample uses
verificationStatus; v1.2.0-beta's table calls ituserVerificationStatus. Both are read. - The shape of an expired-token rejection isn't documented. The driver treats HTTP 401/403,
2079, or a baremessagementioning "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 anid_tokenand no error code. - The callback
signatureis 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'strxId.
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), andrefundStatus()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.