Files
android/docs/bmlapi/16-card-payment.md
T

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 /paynow host is fronted by Cloudflare and returns 403 to the okhttp/* User-Agent. Send a browser UA (BML_WEB_USER_AGENT) + Accept: text/html…. The api.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 one POLL → THREEDS with 3dsUrl = …/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.

  1. GET <3dsUrl> (render-tds) → a form posting creq to https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>. Capture that URL as the ACS creq URL.
  2. POST creq → the channel picker: radios destValue ∈ {mobile, email, token}, plus hidden creq, authMethod, otpDest, selectChannel, otpChannels, formReqType. The BML token / authenticator is the token channel. Submit: destValue=token, selectChannel=token, authMethod=OOB, otpDest=, formReqType=SUBMIT (keep the hidden creq / otpChannels).
  3. POST channel → the OTP entry page (otpValue input). Submit otpValue=<BML token TOTP>, formReqType=SUBMIT. A wrong/expired code re-renders the OTP page (still with otpValue) 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.
  4. On success the ACS returns a form auto-posting cres to the Mastercard gateway; the gateway returns a form auto-posting the result (order.id, result=SUCCESS, …) to transactions/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

← Card Freeze