12 KiB
Merchant Card Payment (no BML Pay)
BML Merchant Services payment links (https://transaction.merchants.bankofmaldives.com.mv/<id>,
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
merchant has enabled:
| Merchant capability | How it is paid | Doc |
|---|---|---|
BML Pay (bml_mpos) enabled |
Fetch the merchant's QR text, pay it via the normal QR flow | QR Payment |
| Card only (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | this doc |
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
challenge make in a browser. Reconstructed from docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har.
⚠️ This flow is scraped browser/ACS traffic, not a stable API. See Fragility before relying on it.
Hosts
| Purpose | Base URL | Notes |
|---|---|---|
Payment page (/paynow) |
https://transaction.merchants.bankofmaldives.com.mv |
Behind Cloudflare — browser User-Agent required |
| Merchant API | https://api.merchants.bankofmaldives.com.mv |
Tolerates non-browser UA |
| Card tokenisation (Pomelo CDE) | https://api.pay.pomelopay.com |
bin-lookup |
| 3-D Secure ACS (Wibmo) | https://secure-acs2ui-bk2-<dc>.wibmo.com |
Behind Cloudflare; <dc> varies (e.g. indmum-mumrdc, indblr-blrtdc) |
| Card scheme gateway | https://ap.gateway.mastercard.com |
MPGS |
Detecting the merchant type
GET /<id>/paynow returns server-rendered HTML with everything inline in a
window.appData = {…} script. Parse that JSON (the code reads between window.appData = and the
next </script>):
window.appData field |
Meaning |
|---|---|
transaction.state |
QR_CODE_GENERATED normally; CONFIRMED if already paid |
transaction.payAmount / transaction.amount |
Amount in cents (payAmount preferred; falls back to amount) |
transaction.payCurrency / transaction.currency |
e.g. MVR |
merchant.tradingName / registeredName |
Display name |
availableProviders[] |
Contains {value:"bml_mpos", enabled:true} iff BML Pay is enabled |
pomeloJsProviders[] |
Contains "mpgs" when card entry is offered |
pomeloJsKey |
pk_production_… — the card form's auth token (a JWT carrying the merchant id) |
Decision: supportsBmlPay = availableProviders contains an enabled bml_mpos;
supportsCard = pomeloJsKey present && pomeloJsProviders contains mpgs.
Route to the card flow only when !supportsBmlPay && supportsCard.
The
/paynowhost is fronted by Cloudflare and returns 403 to theokhttp/*User-Agent. Send a browser UA (BML_WEB_USER_AGENT) +Accept: text/html…. Theapi.merchants…host is not UA-gated, which is why the PATCHes below work with the default client.
Flow overview
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
PATCH transactions/<id> {fx:"reset"} ─┘
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
POST <ACS creq url> creq → OTP channel picker
POST <ACS creq url> destValue=token… → OTP entry page
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
POST transactions/mpgsNotification/<id> → records the verdict
POST …next-action POLL → TRANSACTION_CONFIRMED
GET transaction…/<id>?wait=1 → 302 merchant redirectUrl (?…&state=CONFIRMED&signature=…)
→ 302 merchant receipt page
1. Announce browser
Two unauthenticated PATCHes the page sends on load (needed by fx/state bookkeeping). Origin /
Referer are the transaction host.
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
Content-Type: application/json
{"activeBrowserId":"<id>_<epoch-millis>"}
PATCH …/transactions/<id>
{"fx":"reset"}
2. Credentials
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
Authorization: <pomeloJsKey> # the pk_production_… from the page
{
"publicKey": {
"publicKeyId": "3edf1db0-…",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
},
"apiKey": "UU8a9m4Q…",
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
}
3. Tokenise the card (bin-lookup)
The card number, CVV and expiry are RSA-OAEP(SHA-1) encrypted with publicKeyPem, Base64
(no-wrap) encoded. The Pomelo JS uses WebCrypto {name:"RSA-OAEP", hash:"SHA-1"} over the plain
strings — the Java equivalent is RSA/ECB/OAEPPadding with
OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT).
| Plaintext encrypted | Field |
|---|---|
| PAN (digits only) | encryptedCardNumber |
| CVV | encryptedCardSecurityCode |
YYMM (year then month) |
encryptedCardExpiry |
POST https://api.pay.pomelopay.com/bin-lookup
Content-Type: application/json
tenant: bankofmaldives
x-api-key: <apiKey>
x-tenant-id:
{
"encryptedCardNumber":"<b64>",
"encryptedCardSecurityCode":"<b64>",
"encryptedCardExpiry":"<b64>",
"externalId":"<id>",
"cardHolderName":"NAME ON CARD",
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
"encryptedCardExpiryYear":"28",
"encSerialId":"<publicKeyId>"
}
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
4. Rate options → 3-D Secure URL
All next-action calls POST to the merchant API with Authorization: <pomeloJsKey>.
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
Authorization: <pomeloJsKey>
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
Response action values:
action |
Meaning | Do |
|---|---|---|
WAIT |
Processing | Poll (below) |
POLL |
Keep polling | Poll |
THREEDS + 3dsUrl |
Challenge required | Run §5 |
TRANSACTION_CONFIRMED |
Paid (frictionless) | Done |
TRANSACTION_FAILED |
Declined | Fail |
Poll body (every 5 s, no browser-info):
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
In the capture:
RATE_OPTIONS → WAIT, then onePOLL → THREEDSwith3dsUrl = …/modirum/render-tds?transactionId=<id>.
5. 3-D Secure challenge (Wibmo ACS)
A chain of auto-submitting HTML forms. Only the render-tds form and the final gateway /
notification forms carry an action attribute — the ACS's channel-picker and OTP forms have
no action; their JavaScript posts back to the same creq URL. So the creq URL (the
render-tds form's action) is the fallback action for every subsequent form.
GET <3dsUrl>(render-tds) → a form postingcreqtohttps://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>. Capture that URL as the ACS creq URL.- POST creq → the channel picker: radios
destValue ∈ {mobile, email, token}, plus hiddencreq,authMethod,otpDest,selectChannel,otpChannels,formReqType. The BML token / authenticator is thetokenchannel. Submit:destValue=token,selectChannel=token,authMethod=OOB,otpDest=,formReqType=SUBMIT(keep the hiddencreq/otpChannels). - POST channel → the OTP entry page (
otpValueinput). SubmitotpValue=<BML token TOTP>,formReqType=SUBMIT. A wrong/expired code re-renders the OTP page with text containing "incorrect" / "expired" — regenerate the TOTP and retry once. - On success the ACS returns a form auto-posting
cresto the Mastercard gateway; the gateway returns a form auto-posting the result (order.id,result=SUCCESS, …) totransactions/mpgsNotification/<id>. Follow both so the verdict is recorded.
Cookies (__cf_bm, _cfuvid) are set by the ACS and must be carried across these POSTs — the
Cloudflare-fronted ACS also requires a browser User-Agent.
6. Confirm
Poll next-action until the recorded verdict surfaces:
action |
Result |
|---|---|
TRANSACTION_CONFIRMED |
Success |
TRANSACTION_FAILED |
Declined |
7. Return to the merchant
The mpgsNotification response is a page whose script sends the browser to
https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1. Once the transaction is
confirmed, that 302s to the merchant's redirectUrl with a BML-signed result, then on to the
merchant's own receipt page:
GET transaction…/<id>?wait=1
→ 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1>
→ 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId> (bill pay: /services/bill-receipt)
(FahiPay's is fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED.)
This hop is required. It's how at least Dhiraagu learns it was paid: a test reload that
stopped at TRANSACTION_CONFIRMED charged the card but never topped up, and opening the
?wait=1 URL in a browser afterwards delivered it. The signature is generated by BML, so the
hop can be replayed later from the transaction id alone.
BmlMerchantCardPayClient does it after every confirmed payment (returnToMerchant): a browser
UA GET that follows the redirects, up to 3 tries, success = the chain ends on a 2xx page. The
merchant host may be behind Cloudflare: plain curl got a 403 on dhiraagu.com.mv, okhttp got
through.
Fragility — what can break
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you learn of breakage from a failed live payment.
| Area | Breaks when | Symptom |
|---|---|---|
| ACS HTML scraping (most fragile) | Wibmo changes field names (destValue/otpValue/creq), the "token" channel label, the error wording, or the form layout |
"Unexpected authentication page" / wrong-OTP loop |
| Cloudflare | /paynow or wibmo.com adds a JS/managed challenge or TLS-fingerprint check |
403; not fixable by UA alone |
| TOTP seed assumption | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
| Pomelo crypto/contract | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | bin-lookup rejects the card |
next-action states |
New/renamed actions, or browser-info becomes validated | Poll never resolves |
| Merchant detection | BML adds other card providers (UnionPay, Apple/Google Pay); non-mpgs card provider |
Misroute to the wrong flow |
window.appData parsing |
Key moved/obfuscated or made dynamically signed | No pomeloJsKey |
| Double-charge | Confirm poll times out but the charge went through | Retry risks paying twice |
| Return to merchant | The merchant's redirectUrl host blocks the client (Cloudflare) or is down |
Charged but not delivered — Success(merchantNotified = false), the app toasts the BML transaction id; opening …/<id>?wait=1 in a browser delivers it |
Maintenance: re-capture a HAR whenever any party updates; expect to touch the ACS form parser
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
gitignored HARs under docs/bmlapi/tmp/ as reference fixtures to diff against.
Related: QR Payment · App side: Card Verification & Merchant Card Pay