Files
android/docs/bmlapi/16-card-payment.md
T
2026-10-01 06:06:57 +05:00

11 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

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 with text containing "incorrect" / "expired" — regenerate the TOTP and retry once.
  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

The merchant's own backend is also notified out-of-band (e.g. fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED).


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

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