Illustration of a phone scanning a QR code at a street stall in Bangkok while arrows carry a payment across the Gulf of Thailand to a Singapore merchant, passing through a central routing hub connected to Malaysia, Indonesia and Thailand

An ASEAN cross-border QR payment works like this: the payer's app parses the EMVCo QR text, checks its CRC16 checksum, detects which scheme (PayNow, DuitNow, PromptPay or QRIS) owns the code, locks an FX quote, and sends an idempotent instruction down the bilateral link to the merchant's country. This post shows how to build that router in Spring Boot.

The regional headlines are all about new links. By December 2025 the region had counted 29 QR and instant-payment linkages, Cambodia now has five bilateral links, and Singapore and Vietnam switched theirs on in July 2026. MAS and the Association of Banks in Singapore are studying a second generation of PayNow ahead of Singapore FinTech Festival on 18 to 20 November 2026. Each of those is a pairwise agreement between two scheme operators. None of them gives the sending bank a single thing to call.

That gap lands on whoever writes the payer-side code at a bank, wallet or acquirer. In practice the scheme operators run the pipes, and your institution runs the part that decides which pipe a given scan should use, what to quote, and what to do when the answer never comes back. This post walks through one scan in plain terms, then the design I would use for an EMVCo QR parser and corridor router in Spring Boot, and closes with the failure modes that generate pager alerts. If you have worked through PSD2 API development, the shape is familiar: several regulated rails and no common hand-off.

Part 1: One scan, end to end

A Thai customer, with a Thai banking app, stands in front of a Singapore stall that has a printed QR code. They scan it. The app reads the text, notices that the merchant template belongs to PayNow rather than PromptPay, and asks an FX service how many baht equal the stall's price in Singapore dollars. The service answers with a rate held for a short window, often around a minute. The customer sees the baht amount, confirms with a fingerprint, and the app sends an instruction into the Thailand to Singapore link. On the Singapore side it becomes a PayNow credit to the merchant. A confirmation travels back, the stall owner hears a chime, and the customer sees a green tick.

Three terms are all you need beyond that: an instant payment scheme is a national rail (PayNow in Singapore, PromptPay in Thailand, DuitNow in Malaysia, with QRIS as Indonesia's QR standard); a linkage is the bilateral connection between two of them; and FX is the conversion step in between.

What surprised me when I looked at the architecture is that there is no ASEAN-wide network. There is Thailand-Singapore, Malaysia-Singapore, Indonesia-Thailand and so on, each negotiated separately, each with its own message details, operating hours and definition of "completed". Mandate-style payments run into the same trap of rules that look uniform and are not, which I covered in Commercial VRP in Spring Boot: each rail's notion of consent, retry and finality differs, and your code has to absorb that difference rather than pretend it away.

My working thesis is that the QR text is standardised and the corridors are not. That is why the router exists. Who runs it? Usually the payer-side institution, or its processor, because that is the party holding the customer relationship, the FX exposure and the compliance obligation. Scheme operators will not run it for you: they connect two ends of one corridor and have no reason to model your customer's wallet balance or your FX margin.

Part 2: What the QR text contains

EMVCo, the body behind the common QR specification, defines the payload as a flat string of TLV records: a two-digit tag, a two-digit length, then the value. Some values contain a nested TLV string.

Here is a complete, valid example I use in tests, a dynamic PayNow-style code for S$4.50 at a made-up stall:

00020101021226370009SG.PAYNOW010120210201912345K0301052045812530370254044.505802SG5919LAU PA SAT STALL 126009Singapore63047598 

Broken apart: 000201 (format 01), 010212 (dynamic), 2637... (template in tag 26, 37 characters long, holding sub-tag 00 SG.PAYNOW, a proxy type, the proxy value and an editable-amount flag), 52045812 (category 5812), 5303702 (SGD), 54044.50 (amount), 5802SG, 5919LAU PA SAT STALL 12, 6009Singapore, and 63047598 (CRC). The identifiers and sub-tag layout above are illustrative; real ones come from each scheme's specification.

The tags I rely on for routing:

Tag Meaning
00 Payload format indicator, always 01
01 Point of initiation: 11 static (reusable), 12 dynamic (one-off, usually carries an amount)
26 to 51 Merchant account information templates, one per scheme the merchant accepts
52 Merchant category code
53 Currency, ISO 4217 numeric (702 SGD, 764 THB, 458 MYR, 360 IDR)
54 Transaction amount, optional
58 Country code
59, 60 Merchant name and city
62 Additional data such as bill or reference numbers
63 CRC, a 4-hex-digit checksum

Tags 26 to 51 are the ones that matter for a router. One printed code can carry several templates, for example a PayNow template in one tag and a card-network template in another. Inside each, sub-tag 00 holds a globally unique identifier naming the owning scheme. Detect the scheme from that identifier, never from the tag number, because tag numbers are chosen by whoever generated the code and two merchants can put PayNow in different slots.

The identifier strings and sub-tag layouts must come from each scheme's current specification and your sponsor bank's onboarding pack. I keep them in configuration, and the values in the code below are placeholders that show shape only.

Step one: reject bad codes cheaply

The checksum in tag 63 is CRC-16 over the whole payload, up to and including the 6304 that introduces the field. The variant is CCITT-FALSE: polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR. A quick self-test is the string 123456789, which must produce 29B1; the sample payload above produces 7598. Validate before parsing anything else. A failed CRC is a damaged print, a camera misread, or a sticker pasted over a legitimate code, and in none of those cases do you want to burn an FX quote or a corridor call.

public final class EmvCrc16 { private EmvCrc16() {} public static int compute(String payloadUpToAndIncluding6304) { int crc = 0xFFFF; for (byte b : payloadUpToAndIncluding6304.getBytes(java.nio.charset.StandardCharsets.UTF_8)) { crc ^= (b & 0xFF) << 8; for (int i = 0; i < 8; i++) { crc = ((crc & 0x8000) != 0) ? (crc << 1) ^ 0x1021 : crc << 1; crc &= 0xFFFF; } } return crc; } public static boolean isValid(String payload) { if (payload == null || payload.length() < 8) return false; int cut = payload.length() - 4; if (!payload.startsWith("6304", cut - 4)) return false; String expected = String.format("%04X", compute(payload.substring(0, cut))); return expected.equalsIgnoreCase(payload.substring(cut)); } } 

The CRC field must be last. I have seen generators append trailing whitespace or a newline, so input is trimmed at the edge and anything still failing the position check is refused. Do not "repair" payloads. An instrument that needs repair is one you decline.

Step two: a parser that does not trust lengths

The TLV walker bound-checks every length. A declared length running past the end of the string is exactly what a hostile input would contain. My parser returns an immutable map of tag to value and throws one typed exception, MalformedQrException, which the API maps to a 422 with a stable error code. Nested templates go through the same parser recursively, capped at depth two, since no scheme I have read about needs more.

Step three: scheme detection from tags 26 to 51

Detection walks every template in 26 to 51, parses sub-tag 00, and matches it against a registry. The result is a list, because a code can legitimately offer more than one scheme.

public record SchemeMatch(String schemeId, int templateTag, Map<String, String> fields) {} @Component public class SchemeDetector { private final Map<String, String> guidToScheme; // from configuration public SchemeDetector(SchemeRegistryProperties props) { this.guidToScheme = props.guidToScheme(); } public List<SchemeMatch> detect(Map<String, String> tlv) { List<SchemeMatch> out = new ArrayList<>(); for (int tag = 26; tag <= 51; tag++) { String raw = tlv.get(String.format("%02d", tag)); if (raw == null) continue; Map<String, String> sub = TlvParser.parse(raw, 1); String guid = sub.get("00"); if (guid == null) continue; String scheme = guidToScheme.get(guid.toUpperCase(Locale.ROOT)); if (scheme != null) out.add(new SchemeMatch(scheme, tag, sub)); } return out; } } 

Unknown identifiers are skipped, not fatal. A Thai app will meet templates for schemes it cannot pay through, and the right move is to ignore them and use the one it supports. Once a scheme is chosen, the instruction has to survive retries and crashes, which is where the transactional outbox pattern in Spring Boot payment systems earns its place. For the settlement work queues, Kafka share groups in Spring Boot payment pipelines are worth a look.

Part 3: The router and the corridor model

Here is the system I would build, shown in the diagram.

Architecture diagram of the QR router: a mobile app sends a scanned payload to an API layer, which runs CRC validation, TLV parsing and scheme detection, then asks the corridor router and FX quote service, and hands an idempotent settlement request through a Kafka topic to per-corridor adapters for PayNow, DuitNow, PromptPay and QRIS, with a reconciliation service reading settlement files

I model a corridor as an ordered pair, source scheme and destination scheme, plus a bundle of properties: which adapter speaks to it, its operating hours and cut-off, per-transaction and daily limits, the currencies it supports, its timeout budget, and whether it returns a synchronous final answer or only an acknowledgement followed by a later status message. Direction matters. Thailand to Singapore and Singapore to Thailand are separate corridors that happen to share a diagram edge.

public record Corridor( String source, String destination, String adapterBean, LocalTime opensAt, LocalTime closesAt, ZoneId zone, BigDecimal perTxnLimit, Duration ackTimeout, boolean asyncFinality) { public boolean isOpen(Instant now) { LocalTime t = now.atZone(zone).toLocalTime(); return !t.isBefore(opensAt) && t.isBefore(closesAt); } } 

Corridor definitions live in configuration and are reloaded without a deploy, because limits and windows change by circular, not by release cycle. Routing is then a lookup: given the payer's home scheme and the list of SchemeMatch entries, return the open corridors, ordered by a preference rule (cheapest FX, then highest recent success rate). If none are open, the user is told which window to retry in, which is kinder than a generic failure.

Locking FX without lying to the customer

The FX service returns a quote: pair, rate, an expiresAt, and a quote id. The router stores it with the payment attempt and shows the payer the converted amount. The rule I would enforce is that the corridor instruction carries the quote id and the exact converted amount the customer confirmed. If expiresAt has passed when the customer confirms, or by the time the message is built, the router discards the quote and asks again rather than quietly re-pricing. Silent re-pricing is how a customer approves 412 baht and is charged 418.

The rate-expiry race is real: the user dawdles on the confirm screen for 55 seconds of a 60-second window, the request takes 8 seconds to reach the adapter, and the quote dies in flight. The fix is to validate expiry with a safety margin equal to your p99 path latency, not at the moment of the tap.

Idempotent settlement

Every payment attempt gets a client-generated idempotency key that travels from the app through to the adapter. The API layer stores it in a unique-constrained table before doing anything else:

@Transactional public PaymentAttempt accept(String idemKey, QrPayRequest req) { return attempts.findByIdemKey(idemKey) .orElseGet(() -> { PaymentAttempt a = PaymentAttempt.create(idemKey, req); attempts.saveAndFlush(a); // unique index on idem_key outbox.enqueue("settlement.requested", a.id(), a.toInstruction()); return a; }); } 

A duplicate request returns the stored attempt and its current state; it does not create a second instruction. The outbox row and the attempt commit together, and a relay publishes to the Kafka topic settlement.requested, keyed by attempt id so that all events for one payment stay ordered on one partition. Concurrent duplicates race on the unique index; the loser catches the constraint violation and re-reads. I also pass the same key as the end-to-end reference in the corridor message wherever the scheme allows, so the destination side can detect a replay even if my side loses its own state.

Per-corridor adapters consume the topic and are the only components that know a scheme's message format. They translate the internal instruction to the corridor's wire format, call the link, and publish settlement.result events. An adapter must be a thin translator with no business rules, so that adding a corridor means adding one class and one configuration entry.

The state machine

I keep the attempt state deliberately small: RECEIVED, QUOTED, CONFIRMED, SENT, COMPLETED, FAILED, UNKNOWN. UNKNOWN is the important one. It means "we sent an instruction and do not know the outcome", and it is a first-class state with its own handling rather than a synonym for failed. Anything that times out after SENT goes there, never to FAILED.

Part 4: Compliance and data constraints

A router like this is a regulated activity before it is a technical one. Points that shape the design:

  • Data localisation and residency. Regulators in several of these markets, including the central banks of Thailand, Malaysia and Indonesia and the Monetary Authority of Singapore, set expectations about where payment data may be stored and processed. Indonesia's rules in particular push toward domestic processing for domestic transactions. Do not assume one central cluster can hold everything; check with your compliance team early and design the storage layer so a corridor's records can live in-region.
  • Licensing. Operating on the payer side generally requires being a licensed bank or payment institution in your home market, and the destination scheme will onboard you through a sponsor or participant arrangement. Your adapter configuration is shaped by that contract.
  • AML and sanctions screening. Cross-border hops are where screening is strictest. Screen the beneficiary name and country from tags 58 and 59 before sending, and keep the screening result and list version on the attempt record. A screening hold needs its own state so that a manual review does not look like a timeout.
  • Amount and purpose reporting. Some corridors cap per-transaction amounts and require a purpose or merchant category, which is why tag 52 is retained, not discarded after parsing.

None of this is exotic, but each item adds a mandatory step in the hot path, and the added latency eats into the FX quote window described above.

Part 5: Failures that page people

These are the failure patterns I would design against, each of which has a recognisable shape in any system of this kind.

The timeout after SENT. The link takes your instruction, then the response never arrives. If you mark the payment failed and the customer retries, you may pay the merchant twice. If you mark it successful, you may have paid nobody. Treat it as UNKNOWN, query the corridor's status endpoint (or wait for the async status message) on a backoff, and only then resolve. Set an upper bound, for example 15 minutes, after which the case goes to a human operations queue rather than retrying forever.

The cut-off boundary. A corridor closes at its cut-off time. An instruction built at 23:59:58 and delivered at 00:00:03 lands in the next window, or is rejected. Check isOpen with a margin and, when close to the boundary, refuse and say when to retry. Include timezone in the corridor config: two schemes six hours apart do not share a day.

The reconciliation mismatch. Your records say 1,000 payments completed; the scheme's settlement file says 998. The two missing ones are usually UNKNOWN attempts that did not settle, and one or two settled attempts that you marked failed. Reconciliation should match on the end-to-end reference, produce three buckets (matched, only-ours, only-theirs), and auto-resolve only the ones with unambiguous evidence. A nightly job that quietly "fixes" differences hides the incidents you need to learn from.

The poison QR. One printed code with an off-spec template (a length field wrong by one) can throw in the parser and, if the consumer is not careful, stall a partition. Parsing happens at the API edge precisely so that malformed input is rejected with a 422 and never reaches Kafka. Anything that does get through to an adapter and fails should go to a dead-letter topic with the original payload, not retry in a tight loop.

Duplicate delivery. Kafka consumers are at-least-once. An adapter that receives the same settlement.requested twice must check the attempt state before calling the link. Combine that with the idempotency key in the wire message and a replay becomes a no-op on both sides.

Quote drift on retry. A retry of an UNKNOWN payment must reuse the original quote id and amount, not request a fresh one. Otherwise the customer is shown one price and settled at another.

What I would build first

Start with the CRC validator and parser with a test file of real-looking payloads, including broken ones; they are cheap and every later layer depends on them. Then a single corridor end to end against a sandbox, with the state machine and the UNKNOWN handling in place from day one, not bolted on after the first incident. Add the second corridor only when the first one's reconciliation runs clean. The QR format is the easy and well-documented part. The routing, FX discipline and failure handling are where your own engineering decisions show up, and they are what a bank or wallet actually has to own once the scheme links go live.