research/gopay-merchant-qris-gateway/index.md

59 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: "GoPay Merchant QRIS gateways — how a merchant account becomes an unofficial payment gateway"
description: "Reverse-engineering audit of three open-source repos (gopay-api-gateaway, gobiz-payment, QrisMerchantID): how a GoPay/GoBiz merchant's own QRIS + private portal APIs are automated into a self-hosted 'payment gateway' — dynamic QRIS via EMVCo TLV, payment confirmation by polling the merchant's own transaction feed, and the HAR-capture/APK-analysis methods used to reverse engineer it. Pinned at Oct 2026 commits."
status: concluded
category: technical
tags: [gopay, gobiz, qris, payment-gateway, reverse-engineering, emvco, har-capture, apk-analysis, api-replay, indonesia, fintech, unofficial-api, gojek]
draft: true
created: 2026-10-15
updated: 2026-10-15
---
# GoPay Merchant QRIS gateways
**Research question:** How do tools like `gopay-api-gateaway`, `gobiz-payment`, and `QrisMerchantID` turn a GoPay/GoBiz merchant account into a "payment gateway API" — how does the mechanism actually work, and how were the underlying APIs reverse engineered?
**Short answer [verified].** There is no fake gateway and no money-routing trick. All three repos automate **two things the merchant can already do with their own account**: (1) **render** a dynamic QRIS for an order — done fully offline by editing the merchant's own static QRIS string (EMVCo TLV: inject the amount into tag 54, recompute CRC16); and (2) **detect** the matching incoming payment by polling the merchant's own transaction feed through GoBiz's private web-portal API, authenticated as the merchant. Settlement still happens on GoPay's normal QRIS rails (buyer's wallet/bank app → merchant's GoPay/GoBiz balance). The "gateway" is presentation + reconciliation automation around the merchant's own account — which is why it works without any licensed payment provider (PJP), and why it sits in a ToS/compliance gray zone.
**How it was reverse engineered [verified].** Mostly *gray-box*, not exploits: the APIs were recovered by capturing the merchant's own web-portal sessions (HAR exports shared between researchers), replaying them request-by-request ("mirror the browser, byte-for-byte"), reading older open-source repos, and extracting endpoint inventories from the public Android APKs (apktool/androguard/strings on the Flutter app). The QR half needed no reverse engineering at all — EMVCo QRIS is a public spec. The ongoing maintenance burden is **client-version drift**: the portal appears to gate requests on an `X-AppVersion` build string and has moved its auth endpoints, so replay clients must track portal releases (observed sequence v3.107 → v3.119 → v3.122 → v3.125 between Jun–Oct 2026).
## Repos audited
| Repo | What it is | Lang · license | Stars (captured 2026-10-15) | Audit note |
|---|---|---|---|---|
| [S3] `ahmadzakiyox/gopay-api-gateaway` | Self-hosted Express gateway: REST API + hosted QRIS checkout page | Node.js · MIT | 233 ★ / 84 forks | `server.js` readable; `login.js` + `sessionManager.js` **obfuscated** |
| [S2] `kavionn/gobiz-payment` | Library: `GoPayMerchant` client + polling watcher | Node.js · MIT | 73 ★ / 29 forks | Fully readable; primary code reference |
| [S1] `AlfinAI/QrisMerchantID` | Python SDK + **the public RE knowledge base** (APK analyses, HAR verification reports, reproduction guide) | Python · MIT | 12 ★ / 2 forks | Rich documentation; parts explicitly runtime-unverified |
## Key findings
1. **[verified] The "gateway" never touches money.** It renders QRIS (offline string edit) and confirms payments (own-account polling). No PJP, no acquiring, no fund flow. [S6]
2. **[verified] Auth = GoID portal replay.** OTP or email+password against `/goid/login/request` + `/goid/token` with copied browser fingerprint headers; JWE bearer tokens, cached on disk and re-obtained on expiry. [S7][S10]
3. **[verified] Payment confirmation = polling the merchant's own feed.** `GET api.gojekapi.com/merchant-analytics/v2/merchants/transactions` (primary) or `POST api.gobiz.co.id/journals/search` (fallback/detail), every ~6 s while a checkout is active; match amount + time; dedupe by transaction id. [S2][S6]
4. **[verified] Anti-double-claim is a known hard problem** (two buyers paying the same nominal). Implemented: claim scoping by a unique `trx_id` + dedupe by `transaction_id`. Recommended additionally: add a unique Rp1–99 code to the billed amount. [S3][S6]
5. **[verified] Reverse engineering was HAR-driven.** Four captured sessions are documented (up to 80 MB / 1063 entries each); replay clients were diffed against capture data header-by-header. Live-credential HARs are treated as crown jewels: gitignored, never committed, masked in reports. [S6][S7]
6. **[verified] The APK analysis is a map of what's possible, not what's used.** ~250 endpoints were inventoried statically from GoPay Merchant 2.3.0 + GoFood Merchant 5.49.0; almost all are marked *unverified at runtime*. The subset actually in use is the HAR-verified portal API above. [S8][S12]
7. **[reported] Version drift breaks clients regularly.** Documented fixes: phone normalisation (bare national digits only), dropping the `login_type` field, moving auth calls to the portal host, and repeated `X-AppVersion` bumps. Issue trackers show OTP delivery and empty-history reports — maintenance is ongoing. [S7][S13][S14]
8. **[verified] Obfuscation sits exactly where it hurts.** The most popular gateway hides precisely the OTP login + session-refresh code (built with `javascript-obfuscator`), so what it does with the merchant's OTP, credentials, and tokens **cannot be audited from source**. [S3]
## Confidence labels used in this report
- **[verified]** — read directly in pinned source code, or corroborated by a documented live capture.
- **[reported]** — asserted by a source (repo author, capture report); plausible and consistent, not independently confirmed by us.
- **[unverified]** — explicitly flagged as untested/unconfirmed in the sources themselves.
- **[inference]** — this report's own analysis, not a source claim.
## Files
- [[gopay-merchant-qris-gateway/analysis|Analysis — how the mechanism works]]
- [[gopay-merchant-qris-gateway/reverse-engineering|Reverse engineering — methods & drift playbook]]
- [[gopay-merchant-qris-gateway/repos|Repo audits & comparison]]
- [[gopay-merchant-qris-gateway/security|Security, legal & operational risks]]
- [[gopay-merchant-qris-gateway/sources|Sources & citations]]
## Open questions
1. **[unverified] Unit-handling discrepancy across implementations:** `gobiz.js` and `QrisMerchantID` both divide `gross_amount` by 100 (minor units, i.e. sen), while the gateway's `verifyPayment()` compares the raw field against a rupiah input with no normalisation. Either it was validated against a different response shape, or its `/check-payment` never matches. Resolvable only with a live merchant account. Details in [[gopay-merchant-qris-gateway/analysis|analysis]] §4 (Money units).
2. **[unverified] App-side protocol remains traffic-unverified:** the GoPay Merchant app's own API set (realtime WebSocket `raccoon` events, `qris-link` charge endpoints) is inventoried from static analysis only; a realtime (non-polling) watcher on the app protocol is the obvious next build for anyone with a device lab. [S8][S15]
3. **[unverified] Request signing:** no static signing secret exists client-side and no signing was observed on the web path; whether app-side calls add per-request signatures is unknown. [S15]
4. **[reported] Suspension triggers:** community threads tie account bans to aggressive polling and to transaction spikes; neither is confirmed against any GoPay statement. [S4][S14]