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

174 lines
18 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: 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]
## 10. Gateway equivalence — why the seller's backend sees a "gateway API"
The full loop — **buyer pays by QRIS → seller gets an authoritative confirmation → seller proceeds (fulfil, deliver, activate)** — is precisely the loop a commercial payment gateway exists to serve. A licensed PJP gateway (Midtrans, Xendit, DOKU, …) provides it via three services. This stack emulates all three, sourcing each from a non-gateway surface:
**1. Payment initiation (the "charge" call).**
- *Gateway:* `POST /charge` → the provider registers a transaction and returns a pay-handle (QR/VA/redirect) with provider-side TTL.
- *Here:* no call and no registration — the handle is **derived offline** from the merchant's own static QR (TLV edit + CRC16). Nothing exists in any provider system until the buyer's app acts on the QR; the 5-minute expiry is local bookkeeping only.
**2. Status / notification (the webhook).**
- *Gateway:* signed push callback to the store, plus a status-query API.
- *Here:* **pull instead of push** — the tool polls the merchant's own transaction feed (~6 s) and synthesizes the "order paid" event locally. No webhook can be provisioned because the flow has no gateway product behind it; the notification channel is "the merchant can see their own payments", made machine-readable by session replay.
**3. Order binding (transaction ↔ order, 1:1).**
- *Gateway:* the provider maintains handle ↔ order mapping and guarantees a payment is claimable once.
- *Here:* reconstructed locally — amount + time-window matching + a claim registry (`trx_id` scoping / seen-set dedupe), with the unique-nominal (Rp1–99) trick as the practical binding. This is *why* the double-claim problem exists here at all: it is the gap left by the missing provider-side registry.
**Net effect.** The store-facing contract is gateway-shaped (create QR → poll/wait → receive "paid" → fulfil). Functionally, for the seller's backend, this **is** a payment gateway API. Structurally it is a **merchant-side reconciliation emulator** built from consumer-grade surfaces — which is also why its failure classes (drift, suspension, claims lost on restart, no delivery guarantees) are exactly the guarantees a real gateway product sells. Coverage is the happy path: paid/pending/expired for QRIS/GOPAY payins; refunds, cancellations, disputes, retryable webhooks and reconciliation reports sit outside the emulation (feeds expose refund statuses, but no audited repo drives those workflows).
[inference throughout — this section maps the audited mechanics onto standard gateway concepts; the per-leg mechanics are sourced in §3–§8.]