# Merchant Card Payment (no BML Pay) BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/`, 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](13-qr-payment.md) | | **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](#fragility--what-can-break) 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-.wibmo.com` | Behind Cloudflare; `` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) | | Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS | --- ## Detecting the merchant type `GET //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 ``): | `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 //paynow → window.appData (merchant type, pomeloJsKey) PATCH transactions/ {activeBrowserId} ─┐ announce browser PATCH transactions/ {fx:"reset"} ─┘ GET public-client/credentials/ → 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 creq → OTP channel picker POST destValue=token… → OTP entry page POST otpValue= → auto-POST form (cres → gateway) POST cres → auto-POST form (→ mpgsNotification) POST transactions/mpgsNotification/ → 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/ Content-Type: application/json {"activeBrowserId":"_"} ``` ``` PATCH …/transactions/ {"fx":"reset"} ``` --- ## 2. Credentials ``` GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/ Authorization: # the pk_production_… from the page ``` ```json { "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: x-tenant-id: { "encryptedCardNumber":"", "encryptedCardSecurityCode":"", "encryptedCardExpiry":"", "externalId":"", "cardHolderName":"NAME ON CARD", "encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name "encryptedCardExpiryYear":"28", "encSerialId":"" } ``` ```json { "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" } ``` --- ## 4. Rate options → 3-D Secure URL All `next-action` calls POST to the merchant API with `Authorization: `. ``` POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action Authorization: { "action":"RATE_OPTIONS", "transactionId":"", "cardBrand":"V", "bin8":"42136300", "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](#5-3-d-secure-challenge) | | `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done | | `TRANSACTION_FAILED` | Declined | Fail | Poll body (every **5 s**, no browser-info): ``` POST …/next-action { "action":"POLL", "transactionId":"" } ``` > In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with > `3dsUrl = …/modirum/render-tds?transactionId=`. --- ## 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/`. 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=`, `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/`**. 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](13-qr-payment.md) · App side: [Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md) [← Card Freeze](15-card-freeze.md)