Three days, nine phases, one rule: integrating EPS without trusting it
How I added the EPS payment gateway (bKash, Nagad, Rocket and cards) to Royal Subz by trusting exactly one thing: my own server asking EPS whether the money arrived.
Most Royal Subz customers pay in taka, through bKash or Nagad. Until this week, that was also the slowest way to pay. The customer sent money from their phone, copied a transaction ID into a form, and waited for me to find it in my bKash history and press Approve. At night, that wait lasted until I woke up.
The automated paths I had built for the invoice system, NOWPayments and Binance verification, only served people paying in crypto. The customers who most needed instant checkout were the ones still waiting on me.
EPS (Easy Payment System) is a Bangladeshi hosted gateway that covers bKash, Nagad, Rocket, Upay, local and international cards, and internet banking. The integration took three days, from reading the documentation on 29 September to switching on the first EPS method at 20:16 UTC on 1 October. It shipped in nine phases, and very little of the hard work was calling an API.
I tested the documentation before I trusted it
EPS gives merchants an API guide, a separate notification guide inside the merchant dashboard, and seven sample integrations on GitHub. Before writing any code, I ran every assumption against the public sandbox. Several were wrong.
- Errors arrive as HTTP 200. “Invalid hash provided.” and “Transaction Not Found.” come back with a success status and an error code in the body. A bad bearer token, oddly, is a 404. Any code that classified failures by status code would have been wrong in both directions.
- “Cancel” is the default. Ask EPS about a payment the customer hasn’t even opened yet and it answers
Cancel. That doesn’t mean anyone cancelled. OnlySuccessmeans paid; every other status means not yet. - Tokens last about two minutes. The login response gives an expiry in Bangladesh local time with no time zone, from a server clock running about five minutes slow. Read as UTC, an expired token would look valid for six more hours. I stopped caching tokens: each operation logs in fresh.
- The hash covers who you are, not what you send. Every call carries an HMAC over one value: the username, or the transaction ID. It never covers the amount.
- The method you choose is ignored. I sent
financialEntityIdas bKash, Nagad, Visa, a bank, and a nonexistent 99. Every time, EPS’s hosted page offered all fifteen methods.
The sample code was the bigger warning. Several official integrations mark an order paid straight from ?Status=Success on the return URL. One calls the Verify API and then completes the order whatever it answers. Copying a sample would have produced a checkout where anyone could mark an order paid by typing a URL.
Every behaviour that mattered was measured, not taken from the docs. The documentation became a list of claims to test.
Three messengers, one judge
A payment can reach Royal Subz in three ways. The customer’s browser returns to /pay/<token>/eps/success. EPS posts an IPN, an encrypted notification, to a webhook. If both are lost, for example because the customer closed the tab and the notification never arrived, a background reconciler checks every open attempt, less often as it ages, and gives up after 24 hours.
None of them is allowed to credit a payment.
Three messengers
Customer's return
/pay/…/eps/success
Anyone can type it
IPN from EPS
Encrypted, not signed
Answered 200 before reading
Reconciler
Cron, every 5 minutes
Gives up after 24 hours
verifyAttempt()
Our server calls EPS's Verify API with our own credentials
The only path that credits money
Credited
Success, and the exact amount
Pending Subscriptions + receipt
Held for a human
Wrong amount · paid twice · late
Admin banner: Accept or Mark handled
Not paid yet
Anything but Success
"Cancel" is EPS's default
The return URL is the obvious case: anyone can type it. The IPN was subtler. EPS encrypts it with AES-256-CBC using a secret that only EPS and Royal Subz hold. That looks like authentication, but it isn’t: there is no MAC and no signature. Worse, the guide recommends one response code when a payload fails to decrypt and another when it decrypts into bad JSON. That difference is enough for a patient attacker to probe the endpoint until they can forge a convincing “Success” without ever learning the secret.
So the IPN handler reads at most 64 KB and replies 200 OK immediately, identically, to every request. The real work happens in the background. Neither the response nor its timing reveals whether a payload decrypted. Even when one does, the handler uses only the transaction ID inside it, as a pointer to which payment to go and check.
All three paths call the same function, verifyAttempt(), which asks EPS’s Verify API using our own credentials. A Success for exactly the amount we asked for is credited through the same atomic confirm_invoice_payment transaction every other gateway uses. Three cases are never credited automatically: a success for the wrong amount, a second payment on an invoice that’s already paid, and a payment that lands after the invoice expired. Each goes to an admin banner with two actions: Accept, or Mark handled with a note.
In testing, I typed the success URL for an unpaid invoice by hand. It checked six times and ended on “Waiting for confirmation”. I sent a forged IPN claiming success, encrypted exactly the way EPS does it. The invoice stayed pending.
Each retry of an invoice is a new attempt with a new transaction ID, because EPS rejects a reused ID forever. That also means one invoice can be paid twice, from two devices. The attempts table records the second one as paid_duplicate instead of creating a second order.
Fees the customer sees and the business never loses
EPS charges the merchant a commission: 2% on mobile wallets, 2.1% on internet banking, 2.2% on cards. I decided customers would pay it, openly, as a fee line, and that the business should receive exactly the product price after EPS takes its share.
That rules out simply adding 2%, because 2% of the price is less than 2% of the larger total. The fee has to be grossed up:
EPS methods charged = ceil(price ÷ (1 − rate))
manual bKash charged = ceil(price × (1 + rate))
For a $17.99 order at a fixed ৳120 per dollar, the base is ৳2,158.80:
| Method | Customer pays | EPS keeps | Business receives |
|---|---|---|---|
| Card, 2.2% | ৳2,208 | ৳48.58 | ৳2,159.42 |
| Mobile wallet, 2% | ৳2,203 | ৳44.06 | ৳2,158.94 |
Every taka amount rounds up, so the business is never a paisa short. The arithmetic runs in whole paisa, not floating point, because 2158.8 / 0.978 in IEEE doubles can land a hair either side of a whole number. It lives in one module: the server charges with it and the website imports it to display prices, so the amount a customer reads comes from the same code that charges them. Its tests sweep about 1,600 price and rate combinations to prove the business never comes up short.
Rounding up exposed an older inconsistency. Product pages rounded taka to the nearest whole number, so a $10.01 product showed ৳1,201 but would have charged ৳1,202. All four places that convert prices now share one rule.
Because EPS ignores the method chosen on our side, a customer can pick Mobile Wallet at 2% and then pay by card at 2.2% on EPS’s page. I accepted that gap rather than pretend it away. Verify reports the method actually used, and it’s stored on every payment, so the cost of the gap can be measured instead of hidden.
The customers who trusted us first pay nothing
Charging fees from launch day felt wrong for the people who paid before Royal Subz had anything to prove. So everyone with at least one confirmed payment became a GodFather, with no payment fee on EPS or manual bKash.
The flag doesn’t live on the customer’s profile. Users can update their own profile row, so a column there could be switched on by the customer. GodFather status lives in a separate table that only admins can write to.
On go-live day, the order of operations mattered more than the code. The grant ran first, for 83 customers, then the 2% fee on manual bKash, and only then the EPS methods. In any other order, an existing customer could have been shown a fee they didn’t owe. The thank-you email goes out 40 a day, because the email system has a 100-a-day budget, and a receipt or password reset must never be the message that gets dropped.
Fixing fulfilment before adding a gateway
The first real phase didn’t touch EPS at all. Automated payments already went to awaiting_setup and into a Pending Subscriptions queue, where I add credentials and activate. Manual approvals skipped that queue and went straight to active. Worse, they started the subscription clock when the customer submitted the transaction ID, which could be hours before I approved it.
Adding a third automated gateway on top of two fulfilment paths would have made that inconsistency permanent. Now every confirmed payment, manual or automated, lands in the same queue, starts its clock at confirmation, and sends one receipt listing product, variant, period, quantity, fee and discount. Activation has two buttons: Activate, and Activate & Notify.
Shipping inert on a live database
Royal Subz has no staging database. Migrations run against production, and the local dev server talks to the live project. So every phase shipped switched off.
- Phase 0 Can Supabase reach EPS? Diagnostic
Region check from every edge region
- Phase 1 One fulfilment path Admin side
Manual and automated payments → Pending Subscriptions
- Phase 2 Fees and GodFather Inert
Every rate at 0%, nobody granted
- Phase 3 EPS core Disabled
Methods seeded disabled; test invoices only
- Phase 4 IPN and reconciler Disabled
Forged IPNs and lost returns tested
- Phase 5 EPS Log Admin side
Per-attempt timeline, heartbeat, re-verify
- Phase 6 Profit and refunds Admin side
Fee and commission in one profit formula
- Phase 8 Artwork Hidden
Footer strip hidden until EPS is on offer
- Phase 7 Go-live, 1 October, 20:16 UTC
- 1Live credentials, region check again
- 2৳13 by bKash, ৳123 by card: my own money
- 3Grant 83 GodFathers
- 4Turn on the 2% bKash fee
- 5Enable EPS
Fees went out at 0%, and the EPS methods were seeded disabled. Testing end to end normally means enabling a method, which would have shown real customers a checkout wired to the sandbox. Instead, I added test invoices: an admin can lock a single invoice to a disabled EPS method, and nothing else can reach it.
The live database still caught me out. The first migration failed in production on a column the repository said existed, because an earlier email migration had never been applied. After that, I checked every migration against the live schema with read-only queries before writing it.
Twice, I switched Mobile Wallet on just to preview it on localhost, forgetting that localhost uses the live database. For those minutes, real customers could see it, with the fee on and no GodFathers granted. Nobody paid. After that, previews used fake data.
One bug did reach production. Scripted edits had silently swallowed the $ in six template strings, so visitors paying in dollars saw “5.99” instead of “$5.99” on product pages, at checkout and in two emails. I fixed it with plain string concatenation, added regression tests, and stopped writing currency code through shell one-liners.
Going live with my own money
Binance had once rejected Supabase’s US region with a geo-block that only appeared in production, so Phase 0 was a diagnostic: could Supabase’s edge regions reach EPS at all? Singapore, Mumbai, Ireland and Virginia all passed, first in the sandbox and again with live credentials.
Then I paid myself. A $0.10 payment link became ৳13 by bKash. EPS took exactly ৳13.00 and passed ৳12.74 to the store: a commission of exactly 2.00%, where the sandbox had shown 1.70%. The same test by card was declined, because cards refuse amounts that small, so I tried $1.00: ৳123, with a commission of exactly 2.20%. The gross-up rates matched EPS’s live rates to the hundredth of a percent.
Both payments were credited by the IPN, a few seconds before my browser came back. The browser’s return then verified the same payment, and the log said “Credited” twice. There was still only one payment and one receipt, because confirmation is idempotent and the receipt can only be sent once. The second log line now says “Already credited”.
What a refund looks like
EPS has no refund API, and its dashboard only refunds the full amount. I don’t use it. EPS payments are refunded by hand from bKash, in full or in part, and recorded in the refund ledger like any other payment. Profit is now calculated in one place, as price plus fee, minus EPS’s commission, refunds and cost. Before, four screens calculated it four slightly different ways.
The gateway is a witness
The integration took nine phases, more than a hundred new tests, and one rule underneath all of it. A return URL can be typed by anyone. A callback can be forged. A status can mean something other than its name. The only thing that credits a payment is my server asking EPS directly, with its own credentials, whether the money arrived, and checking that it’s the amount it asked for.
Everything else EPS sends is just a reason to go and check.