14 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 (still withotpValue) and "The OTP code you entered is incorrect Please try again." — wait for the next TOTP window, regenerate and retry once. Rejected twice, the payment stops ("The bank rejected the BML token code"). The app also never sends a code with under 5 s left in its window. - 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 |
A decline after 3-D Secure doesn't arrive this way. In the Ooredoo capture, the card passed
3-D Secure (mpgsNotification got result=SUCCESS, gatewayRecommendation=PROCEED) and was then
declined for insufficient funds. The browser's ?wait=1 went to ?error=1 instead of the
merchant, and the transaction stayed payable: state still QR_CODE_GENERATED, hasError: true,
allowRetry: true, and a new paymentErrorHistory entry:
{"date":"…","vendor":"mpgs","code":"INSUFFICIENT_FUNDS",
"reason":"Transaction declined due to insufficient funds",
"customerVisibleDescription":"Insufficient funds. Please use another card or payment method."}
The same link was then paid successfully after topping up the card. So while polling,
BmlMerchantCardPayClient also reads the transaction (the load PATCH,
BmlMerchantTxnClient.paymentErrors) and stops with customerVisibleDescription as soon as an
entry newer than the ones there before the attempt shows up.
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.)
Ooredoo's callback isn't a redirect: my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED is a
200 page whose <body onload="document.forms['wtmpay'].submit()"> posts the result
(order_id, bml_transaction_id, bml_response=CONFIRMED, payment_status=success, …) on to
www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml, which lands on
/payment-status?order_id=…&status=1. returnToMerchant submits such auto-posting forms too
(up to 2).
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 and auto-submitted forms, 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