Home / Node.js / EPS Experimental
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
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:
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
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
pay.charge()fetches a token (/v1/Auth/GetToken), caches it until it expires, and calls/v1/EPSEngine/InitializeEPS.merchantTransactionIdis this attempt'sgatewayReference: 20 digits, new for every attempt.CustomerOrderIdis your reference plus a per-attempt suffix.ValueAandValueBcarry the payment id and your reference.- EPS's
TransactionIdis stored inpayment.gatewayPaymentId.
- 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.
- 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 ignoresStatusand callsCheckMerchantTransactionStatusto get the result. - A payment is completed only when the status API says it was paid and
TotalAmountmatches. EPS's transaction id goes intopayment.gatewayPaymentIdandpayment.gatewayTransactionId. - 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": "<iv>:<ciphertext>"}), 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
ipnWindowminutes, newest first, up toipnSweepLimitof 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 thecacheyou pass tocreateBdPayGate, so with several processes use a shared cache (for exampleredisCache(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: nulland anerrorMessage. The driver reads the body, not the status code. -
Mixed key casing and literal
nulls. Requests mix camelCase and PascalCase. Responses are read case-insensitively, andnullor""counts as missing. -
Escaped return parameters. EPS can HTML-escape the separators in the return URL (
&MerchantTransactionId=). The driver strips theamp;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. KeepipnSweepLimitsmall. -
Origin/IP checks. EPS may only accept calls from your registered domain or IP. If your servers have no fixed IP, pass a
fetchthat goes through a proxy with one: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 usenode:crypto, loaded lazily. It runs on Node, Bun, Deno and Cloudflare Workers withnodejs_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,CompleteandPaidas paid;Failed,Failure,Fail,DeclinedandRejectedas failed;Cancel,Canceled,Cancelled,AbortedandAbortas cancelled. Anything else is pending. - Whether
deviceTypeIdandtransactionDateare required.transactionDateis always sent;deviceTypeIdonly 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
expireDateor the JWTexpclaim, and falls back totokenTtl. - 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.