# PayMV QR Format Documents the decimal TLV QR format used by the PayMV/Favara payment network in the Maldives — both generation (to receive payment) and parsing (to initiate a transfer by scanning). --- ## Encoding PayMV QRs use a **decimal TLV** encoding — not binary BER-TLV. Every field is represented as ASCII text: ``` <2-digit decimal tag><2-digit decimal length>... ``` Example — tag `59`, value `"AHMED ALI"` (9 chars): ``` 5909AHMED ALI ``` Tags and lengths are always exactly 2 decimal digits. Fields are concatenated directly with no separator. --- ## Root-Level Tags | Tag | Field | Notes | |---|---|---| | `00` | Format indicator | Always `"01"` | | `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR | | `26` | Merchant account information | Container — see sub-tags below | | `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs | | `52` | Merchant category code | `"0000"` (generic) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | | `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) | | `58` | Country code | `"MV"` | | `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen | | `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown | | `62` | Additional data field | Container — see sub-tags below | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `80` | Supplementary data | Container — timestamp and domain | --- ## Merchant Account Info — Tag `26` Sub-Tags | Sub-Tag | Field | Example value | |---|---|---| | `00` | Domain | `"mv.favara.mpqr"` | | `01` | Acquirer BIC | `"MALBMVMV"` (see table below) | | `02` | Acquirer BIC (repeated) | Same as `01` | | `03` | Account number | Beneficiary account number | | `05` | Mobile number | E.164 format (e.g. `"+9607654321"`); optional | | `10` | Network indicator | `"IPAY"` | ### Acquirer BIC Mapping | Bank | Acquirer BIC | |---|---| | BML | `MALBMVMV` | | MIB | `MADVMVMV` | | Fahipay | `FAHIMVMV` | --- ## Additional Data — Tag `62` Sub-Tags | Sub-Tag | Field | Notes | |---|---|---| | `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card | | `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` | --- ## Supplementary Data — Tag `80` Sub-Tags | Sub-Tag | Field | Notes | |---|---|---| | `00` | Domain | `"mv.favara.mpqr"` | | `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` | | `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree | --- ## CRC-16 The checksum uses **CRC-16/CCITT-FALSE** (polynomial `0x1021`, initial value `0xFFFF`). The CRC is computed over the entire payload string up to and including `"6304"`, then the 4-digit uppercase hex result is appended. ```python def crc16(data: str) -> str: crc = 0xFFFF for c in data: crc ^= (ord(c) & 0xFF) << 8 for _ in range(8): if crc & 0x8000: crc = ((crc << 1) & 0xFFFF) ^ 0x1021 else: crc = (crc << 1) & 0xFFFF return format(crc, '04X') ``` --- ## Generating a Receive-Payment QR To create a QR that others can scan to pay you: ``` 00 02 01 ← Format indicator 01 02 11 ← Static QR 26 ← Merchant account info 00 15 mv.favara.mpqr 01 08 MALBMVMV ← Acquirer BIC (BML example) 02 08 MALBMVMV ← Repeated 03 05 <+960XXXXXXX> ← Optional phone 10 04 IPAY 52 04 0000 ← MCC 53 03 462 ← MVR 54 ← "%.2f". Open amount: omit (BML) / "***" (Fahipay) 58 02 MV 59 ← Uppercased 60 06 LD<4 random digits> ← Fahipay only 62 05 ← Bank-specific, see below 08 ← Omit if blank (BML) / "PAYMENT" (Fahipay) 80 00 15 mv.favara.mpqr 01 ← Timestamp 6304 ``` For Fahipay, tag `01` is `12` (dynamic) when an amount is set. --- ## Reference (tag 62→05) The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`). ### BML — base-32 account number + amount ``` reference = base32(accountNumber) + amountAsTyped ``` - `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first - `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs - Capped at 25 characters. A non-numeric account number falls back to 9 random characters | Account | Amount | Reference / vertical text | |---|---|---| | `7730000188362` | — | `70V3UKKUA` | | `7730000188362` | `100` | `70V3UKKUA100` | Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card. ### Fahipay — `P` + 9 random characters Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`. ### Others 9 random uppercase alphanumeric characters. --- ## Real Receive QRs (Reference Samples) Decoded from QR images generated by the official apps (CRC verified). **BML app** (open amount): ``` 00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6 ``` Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`). **Fahipay app**, open amount: ``` 00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707 ``` **Fahipay app**, amount `55`: ``` 00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304 ``` --- ## Fahipay (Work in Progress) > ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app. Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid: | Change | Status | |---|---| | Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) | | Name `59` uppercased | Done | | `54` = `***` for open amount, `01` = `12` with an amount | Done | | `60` = `LD` + 4 random digits | Done | | `62→08` defaults to `PAYMENT` | Done | | `62→05` shaped `P` + 9 chars | Done | | `80→02` = `0005` (amount QRs only) | Not done | The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it. --- ## Parsing a PayMV QR (Incoming Scan) When scanning a QR code, extract the relevant fields: | Field | TLV path | Used for | |---|---|---| | Account number | root→`26`→`03` | Transfer destination | | Amount | root→`54` | Pre-fill transfer amount (may be absent) | | Merchant name | root→`59` | Display recipient name | | Purpose | root→`62`→`08` | Pre-fill transfer remarks | --- ## Extracting a BML Gateway URL from a Combined QR Combined QRs (e.g. Fahipay card QRs that embed a BML gateway payment URL) encode the BML URL at a fixed TLV path: ``` root tag 35 → sub-tag 20 → sub-sub-tag 01 ``` The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Extract it and hand off to the [BML QR Payment flow](../bmlapi/13-qr-payment.md). Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly. `PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr` supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`. --- ## BML POS QR (`mv.com.bml.qtr`) BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain (tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01` holds a bare reference instead of a URL. Example (CRC verified, same CRC-16/CCITT-FALSE as above): ``` 00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA ``` | TLV path | Value | Notes | |---|---|---| | `00` | `01` | Format indicator | | `01` | `12` | Dynamic QR | | `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) | | `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `:<32 hex>` | | `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) | | `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) | | `52` | `0000` | MCC | | `53` | `462` | MVR | | `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` | | `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code | | `62`→`05` | `dtyams497d` | Reference / bill number | | `62`→`08` | `POPE` | Purpose / terminal label | | `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format | | `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp | ### Pay-Request Lookup Key The lookup key is the **bare reference**, Base64-encoded without padding: ``` GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk ``` BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with `NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must* carry is `accept: application/json` — see [QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers). Confirmed against the live API (the example QR had already expired, so BML answered `103` rather than with merchant details — but `103` means the reference itself resolved): | Key tried | Response | |---|---| | `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised | | the whole EMV payload | `112` — "Unsupported payment link" ❌ | | `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix | `PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so exactly one request is made either way. --- ## Example Payload Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`: ``` 000201010211268...520400005303462 5802MV5909AHMED ALI6221050970V3UKKUA0804Rent 80...63044A2B ``` (values abbreviated for clarity — actual tags are concatenated with no whitespace) ---   --- [← README](README.md)     **Next →** [Account Parser Architecture](19-parsers.md)