gopay-merchant-qris-gateway: add topic — GoPay/GoBiz merchant→QRIS gateway reverse-engineering audit (gopay-api-gateaway, gobiz-payment, QrisMerchantID, pinned commits)
This commit is contained in:
parent
d512cd7514
commit
376a811476
6 changed files with 589 additions and 0 deletions
154
gopay-merchant-qris-gateway/analysis.md
Normal file
154
gopay-merchant-qris-gateway/analysis.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
title: Analysis — GoPay Merchant QRIS gateways
|
||||
description: Mechanism deep-dive — GoID portal auth, the merchant transaction feeds, offline EMVCo dynamic QRIS generation, the polling watcher, claim/dedupe design, and how the three repos differ.
|
||||
status: concluded
|
||||
category: technical
|
||||
tags: [gopay, gobiz, qris, emvco, payment-gateway, api-replay, mechanism]
|
||||
draft: true
|
||||
created: 2026-10-15
|
||||
updated: 2026-10-15
|
||||
---
|
||||
|
||||
# Analysis — GoPay Merchant QRIS gateways
|
||||
|
||||
> Part of [[gopay-merchant-qris-gateway/index|GoPay Merchant QRIS gateways]]
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
GoPay/GoBiz does not offer small merchants a public, self-serve "payment gateway API". Officially, automated QRIS acceptance goes through licensed **PJP** gateways (Midtrans, Xendit, DOKU, …) with business onboarding and fees. Meanwhile every GoPay/GoBiz merchant already has: a registered QRIS (the static QR in the app), and a portal/app that shows every incoming payment in real time. This ecosystem closes the gap with automation instead of licensing [inference, consistent with [S6]]:
|
||||
|
||||
- **Payment presentation** — generate a dynamic QRIS per order from the merchant's own static QRIS string, offline.
|
||||
- **Payment acknowledgment** — watch the merchant's own transaction feed for the matching incoming payment, then confirm to the store backend.
|
||||
|
||||
Nothing else is needed for the common use case (web store / bot / WhatsApp shop that needs "did this order get paid?" answered automatically). No funds move through the tool; settlement follows normal QRIS rails to the merchant's own balance.
|
||||
|
||||
## 2. Architecture at a glance
|
||||
|
||||
```text
|
||||
┌─────────┐ scans ┌──────────────────┐ normal QRIS rails ┌─────────────────┐
|
||||
│ Buyer │ ─────────► │ Dynamic QRIS │ ────────────────────► │ GoPay / merchant│
|
||||
│ (any │ │ (offline TLV edit│ (buyer's app pays, │ balance / bank │
|
||||
│ QRIS │ │ of merchant's │ settlement T+…) │ (own account) │
|
||||
│ wallet) │ │ static QR) │ └────────┬────────┘
|
||||
└─────────┘ └────────▲─────────┘ │
|
||||
│ rendered │ transaction appears
|
||||
┌────────┴─────────┐ │ in merchant feed
|
||||
│ Store backend / │ poll every ~6 s │
|
||||
│ bot / gateway │ ────────────────► GoBiz/Gojek private APIs
|
||||
│ process │ ◄──────────────── (merchant's own token)
|
||||
└──────────────────┘ "payment found for Rp N at time T"
|
||||
```
|
||||
|
||||
The "gateway" is everything in the bottom box. Money flow and the tool are completely separate — the tool only *reads* the merchant's own data and *writes* a QR payload locally. [verified: code of all three repos]
|
||||
|
||||
## 3. Authentication — GoID portal replay
|
||||
|
||||
All three repos authenticate the **merchant's own account** against GoBiz's web login ("GoID"), by replaying what the browser portal does. Endpoints and shapes below are capture-verified ("HAR") per [S6][S7][S10]; code refs from [S2][S10].
|
||||
|
||||
**OTP flow** [verified, capture §9.1/§9.4 + code]:
|
||||
|
||||
1. `POST /goid/login/request` — body `{client_id: "go-biz-web-new", phone_number: "<bare national digits>", country_code: "62"}` → HTTP 201 `{data:{otp_token, otp_expires_in: 720, otp_length: 4, next_state:{state:"sms", timer_in_seconds: 120}}}`.
|
||||
- The live portal sends **no `login_type` field** here (some clients send `login_type:"otp"`; the server ignores it). [S6 §9.2]
|
||||
- Phone must be bare national format (`851…`): no `+`, no `62`, no leading `0`. The portal never sends prefixes; a client that passes `0851…` through breaks server-side. [S7]
|
||||
- OTP is **4 digits** per capture — but clients should read `otp_length`/`otp_expires_in` from the response instead of assuming. [S10]
|
||||
2. `POST /goid/token` — body `{client_id, grant_type:"otp", data:{otp, otp_token}}` → 201 `{access_token, refresh_token, dbl_enabled: true}`. The access token is a **JWE** string (~2.3 KB); refresh token ~751 chars. [S7]
|
||||
- Rate limit observed on the request endpoint: 10/min. [S7]
|
||||
|
||||
**Email+password flow** [verified live via capture §9.5]: `POST /goid/login/request` `{email, login_type:"password", client_id}` (validation probe) → `POST /goid/token` `{client_id, grant_type:"password", data:{email, password}}` → same token pair. Prerequisite: email+password must already be set on the merchant account in the portal. [S7][S10]
|
||||
|
||||
**Refresh:** `POST /goid/token` with `grant_type:"refresh_token"`, `data:{refresh_token}` rotates both tokens. [S10; `auth.refresh_session()`]
|
||||
|
||||
**Host split** [verified, Oct 2026 build]: auth calls (`/goid/*`) go to the **portal host** `https://portal.gofoodmerchant.co.id`; merchant/data APIs stay on `https://api.gobiz.co.id`. (Earlier captures had everything on `api.gobiz.co.id`; the Oct 3 2026 capture shows the browser sending auth to the portal host, and the SDK routes accordingly.) [S10 constants.py; S13]
|
||||
|
||||
**Header fingerprint** [verified in code, capture-diffed]: clients copy the portal's browser/device headers so requests are byte-compatible. Notable fields [S2][S10]:
|
||||
|
||||
```
|
||||
Authentication-Type: go-id X-User-Type: merchant
|
||||
x-appId: go-biz-web-dashboard X-Platform: Web
|
||||
x-uniqueid: <random UUID> X-AppVersion: platform-v3.125.0-e1923971 ← portal build string
|
||||
Origin/Referer: https://portal.gofoodmerchant.co.id
|
||||
Gojek-Country-Code: ID Gojek-Timezone: Asia/Jakarta
|
||||
User-Agent / X-PhoneMake / X-PhoneModel / sec-ch-ua… (Chrome desktop impersonation)
|
||||
```
|
||||
|
||||
- **Split-header design** [verified]: the full device header set appears **only** on `/goid/*` auth calls; data calls send a slimmer set. Scanning 589 captured entries found `X-AppVersion` **zero** times outside auth calls. [S7 §3.3]
|
||||
- `X-AppVersion` is the drift hot spot — clients are version-gated. Timeline in [[gopay-merchant-qris-gateway/reverse-engineering|reverse-engineering]] §3.
|
||||
- The journals endpoint additionally needs `Accept: …application/vnd.journal.v1+json` + `X-Client-Id: gobiz-web`. [S6 §9.2][S10]
|
||||
|
||||
**Token lifecycle differences across repos** [verified in code]:
|
||||
- `gobiz.js` [S2 L292–L304, L356–L359, L500–L511]: stores only the access token; probes validity via `POST /v1/merchants/search` (`401` = dead); on 401 it re-runs the **full login** (no refresh-token use).
|
||||
- `QrisMerchantID` [S10]: caches access + refresh; auto-refreshes **once** on an expired-token response, then retries; if refresh is rejected it stops and requires re-login.
|
||||
- `gopay-api-gateaway` [S3]: has a 6-hour background re-verify/refresh cycle (`server.js` L56–L69) and refreshes on 401 via `sessionManager` — whose implementation is **obfuscated** and unauditable (see [[gopay-merchant-qris-gateway/security|security]] §3).
|
||||
|
||||
## 4. Merchant & transaction data endpoints
|
||||
|
||||
The working subset of the API, capture-verified [S6 §9, S10]:
|
||||
|
||||
| Call | Host + path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `api.gobiz.co.id/v1/merchants/search` body `{from, size}` | Merchant list / token probe; returns `hits` with merchant `id` (`G…`) — used as merchant-ID discovery |
|
||||
| GET | `api.gobiz.co.id/v1/users/me` | Portal profile (roles, scopes, merchant_id) |
|
||||
| GET | `api.gojekapi.com/merchant-analytics/v2/merchants/transactions?from&size&statuses&payment_types&start_time&end_time&merchant_ids` | **Primary payment feed** (the watcher's source) |
|
||||
| POST | `api.gobiz.co.id/journals/search` (query DSL) | Detailed ledger + aggregations (fallback / issuer breakdown) |
|
||||
| GET | `api.gobiz.co.id/v1/merchants/payouts?page&per` + `…/payable_detail?merchant_id=` | Payout history + payable balance (discovered from HAR; absent from the JS reference repos) |
|
||||
|
||||
- Defaults mirror the portal exactly: `statuses=SETTLEMENT,CAPTURE,REFUND,PARTIAL_REFUND`; `payment_types=QRIS,GOPAY,OFFLINE_CREDIT_CARD,OFFLINE_DEBIT_CARD,CREDIT_CARD`. [S6][S10]
|
||||
- Analytics transaction objects carry 23 keys including `order_id` (`QRIS-…`), `transaction_status`, `payment_type`, `channel_type` (`STATIC_QR`), `transaction_source` (`GOPAY_INSTORE`), `qris_provider_aspi_issuer/acquirer`, `shares`, `promo_details`. [S6 §9.1]
|
||||
- The journal DSL used by `gobiz.js` [S2 L520–L629] filters to incoming `transaction_share`/`action` categories, excludes `GOSAVE_ONLINE/GoSave/GODEALS_ONLINE` sources, and ranges on `metadata.transaction.transaction_time` — an Elasticsearch-style query an app would never hand-write; it was lifted from the portal.
|
||||
|
||||
**Money units [verified, but with one cross-repo discrepancy].** GoPay answers amounts in **minor units (sen)**: `gross_amount: 10600000` = Rp 106 000; payouts use decimal strings at the same scale. [S6 §9.1, S10 money.py]
|
||||
|
||||
- `gobiz.js` divides by 100 (`tx.gross_amount / 100`) for display and matching [S2 L407, L435, L678–L681].
|
||||
- `QrisMerchantID` exposes raw minor units and provides `to_rupiah()`; its watcher matches on **minor units** (`wait_for_payment(5_000_000)` = Rp 50 000). [S10]
|
||||
- `gopay-api-gateaway`'s `verifyPayment()` [S3 L615–L620] compares `parseInt(tx.gross_amount)` **directly against a rupiah input with no conversion**. If the feed returns sen (as the other two repos and the capture evidence say), that equality can never hold — so either its deployment was validated against a different response shape, or `/check-payment` silently never confirms. **[unverified discrepancy — flagged, not resolvable without a live account.]**
|
||||
|
||||
## 5. Dynamic QRIS — the offline half
|
||||
|
||||
QRIS payloads are **EMVCo TLV strings**: `TAG(2 chars) + LENGTH(2 digits) + VALUE`. To turn the merchant's static QR into a per-order dynamic one [verified in all three repos; public spec, no RE needed]:
|
||||
|
||||
1. Parse TLV fields.
|
||||
2. Set tag **`54`** (transaction amount) to the order amount — insert if absent. Amount is plain rupiah digits, e.g. `25000`.
|
||||
3. Strip old tag **`63`** (CRC); append literal `6304` + recomputed **CRC16-CCITT-FALSE** (poly `0x1021`, init `0xFFFF`).
|
||||
4. Render the resulting string as a QR image.
|
||||
|
||||
Divergence between implementations [verified in code]:
|
||||
|
||||
- `gopay-api-gateaway` **also rewrites tag `01`** from static (`11`) to dynamic (`12`) [S3 L119–L121].
|
||||
- `QrisMerchantID` **deliberately does not touch tag `01`** and warns that "a valid checksum alone is not proof that every bank or wallet will accept the result" — the provider rule (static→dynamic flag) is a payment-layer decision, not something to assume locally. [S10 qris.py + docs/gopay/qris.md]
|
||||
|
||||
Both approaches circulate in the ecosystem; which is "correct" in production is **[unverified]** — it depends on what QRIS acquirers enforce when they see tag 54 with tag 01 still `11`. Both repos' QR strings are reported working by their communities, so in practice acquirer/wallet handling appears tolerant. [reported]
|
||||
|
||||
Why it works at all: the merchant identity fields (tag 26–51 ranges, NMID etc.) are untouched — the QR still routes to the same registered merchant; only the amount (and optionally the initiation flag) is local. The payment then appears in the merchant's own feed under `channel_type: STATIC_QR` [S6 §9.1] — note that even gateway-rendered dynamic QRs land as static-channel transactions, which is a useful signal that the network treats them as merchant-presented static QR with amount filled in. [inference from capture data]
|
||||
|
||||
## 6. Payment watcher — the online half
|
||||
|
||||
The pattern is the same everywhere ("ported from `gobiz.js`" is literally QMID's docstring) [verified in code S2 L632–L748, S10 watcher.py]:
|
||||
|
||||
1. **Seed** — fetch current history, mark every existing transaction as seen. Prevents old payments from being claimed by new orders.
|
||||
2. **Poll** — every **~6 s** (default in all implementations), fetch the last ~30 transactions (`days=1`).
|
||||
3. **Diff** — any transaction id not in the seen set is new → candidate. Seen set capped at 500 entries (memory-leak guard).
|
||||
4. **Match** — amount equal (± tolerance, default 0), optionally time-bounded by order creation.
|
||||
5. **Deadline** — a checkout waits up to ~5 min (300 s) for a match; else the order lapses. The gateway's hosted page mirrors this with a 5-minute countdown and an explicit expiry.
|
||||
|
||||
Notably, ecosystem etiquette (community advice, adopted by QMID) is to **poll only while a checkout is active** — an "on-demand poller" so idle traffic is 0 — to reduce rate-limit/suspension exposure. [S4][S6 §2.3]
|
||||
|
||||
## 7. Order matching, claims, and the double-claim problem
|
||||
|
||||
Two buyers paying the same nominal at the same time is the core race condition. Implemented defenses [verified in code]:
|
||||
|
||||
- **`gopay-api-gateaway`** generates a unique `trx_id` per QR (`TRX-…`) and scopes every claim: transaction → `{qrisId, claimedAt}` map; a transaction already claimed by another QR is skipped for everyone else; same QR re-checks return the same result. Claims expire after 24 h; cleanup hourly. **The map is in-process RAM** — a restart loses all claims (double-claim possible again within 24 h; also multi-instance deployments share nothing). [S3 L16–L53, L566–L653]
|
||||
- **`gobiz.js` / `QrisMerchantID`** dedupe by transaction id in the watcher's seen set; the SDK docs additionally recommend baking a **unique Rp1–99 code into the billed amount** so nominals don't collide in the first place — the recipe "every gateway uses". [S2][S6 §2.4, S10]
|
||||
|
||||
## 8. Store-integration shapes (what the three repos actually offer)
|
||||
|
||||
- **`gopay-api-gateaway` — a service.** Express REST API (`/create-qris`, `/check-payment`, `/transactions`, `/token-status`, `/api/logs`) + a hosted checkout page (`/qr/:id`) with countdown, a manual "check payment" button (deliberately avoiding constant polling), and optional 8 s auto-poll; a public status endpoint (`/api/qr-status/:id`, no API key) so the buyer's page never sees the gateway key. Supports GET or POST, deployable on VPS/cPanel/Pterodactyl/Docker. [S3 server.js L207–L727]
|
||||
- **`gobiz-payment` — a library.** `GoPayMerchant` (init/login/history) + `GoPayWatcher` (EventEmitter; `waitForPayment(amount)`; singleton process-wide poller via `getGoPayWatcher()`); embed it in any bot/backend. History falls back analytics → journal automatically. [S2]
|
||||
- **`QrisMerchantID` — a research SDK.** Facade `GoPayMerchant` with granular services (`auth`, `users`, `merchants`, `transactions`, `payouts`, `watch()`), typed errors (`ApiException` carries `http_status`, `code`, `payload`), retry policy (transport retries with backoff; HTTP errors never retried), offline test suite (100 % coverage claimed), plus a separate ShopeePay provider. [S1][S10]
|
||||
|
||||
## 9. Failure modes & reliability notes
|
||||
|
||||
- **Version drift** — the dominant breakage class: stale `X-AppVersion`/host routing suspected in an OTP regression; fixed by tracking portal builds. [S7][S13]
|
||||
- **Token expiry** — handled with re-login (all) / single refresh attempt (QMID) / 6-hourly refresh (gateway, unauditable internals). [verified in code]
|
||||
- **Feed anomalies** — field report of `getHistory` returning empty for all ranges; maintainer replied it still works (likely account/data scope, not endpoint death). [S14]
|
||||
- **OTP delivery** — an open issue asks why OTP doesn't arrive; consistent with version-gating/format issues rather than a dead flow. [S14]
|
||||
- **Single point of failure** — every design depends on one portal account's session; suspension or forced logout stalls all automation. No repo implements failover to a second account. [inference]
|
||||
Loading…
Add table
Add a link
Reference in a new issue