16 KiB
| title | description | status | category | tags | draft | created | updated | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Analysis — GoPay Merchant QRIS gateways | 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. | concluded | technical |
|
true | 2026-10-15 | 2026-10-15 |
Analysis — 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
┌─────────┐ 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]:
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_typefield here (some clients sendlogin_type:"otp"; the server ignores it). [S6 §9.2] - Phone must be bare national format (
851…): no+, no62, no leading0. The portal never sends prefixes; a client that passes0851…through breaks server-side. [S7] - OTP is 4 digits per capture — but clients should read
otp_length/otp_expires_infrom the response instead of assuming. [S10]
- The live portal sends no
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 foundX-AppVersionzero times outside auth calls. [S7 §3.3] X-AppVersionis the drift hot spot — clients are version-gated. Timeline in gopay-merchant-qris-gateway/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 viaPOST /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.jsL56–L69) and refreshes on 401 viasessionManager— whose implementation is obfuscated and unauditable (see gopay-merchant-qris-gateway/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 incomingtransaction_share/actioncategories, excludesGOSAVE_ONLINE/GoSave/GODEALS_ONLINEsources, and ranges onmetadata.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.jsdivides by 100 (tx.gross_amount / 100) for display and matching [S2 L407, L435, L678–L681].QrisMerchantIDexposes raw minor units and providesto_rupiah(); its watcher matches on minor units (wait_for_payment(5_000_000)= Rp 50 000). [S10]gopay-api-gateaway'sverifyPayment()[S3 L615–L620] comparesparseInt(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-paymentsilently 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]:
- Parse TLV fields.
- Set tag
54(transaction amount) to the order amount — insert if absent. Amount is plain rupiah digits, e.g.25000. - Strip old tag
63(CRC); append literal6304+ recomputed CRC16-CCITT-FALSE (poly0x1021, init0xFFFF). - Render the resulting string as a QR image.
Divergence between implementations [verified in code]:
gopay-api-gateawayalso rewrites tag01from static (11) to dynamic (12) [S3 L119–L121].QrisMerchantIDdeliberately does not touch tag01and 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]:
- Seed — fetch current history, mark every existing transaction as seen. Prevents old payments from being claimed by new orders.
- Poll — every ~6 s (default in all implementations), fetch the last ~30 transactions (
days=1). - Diff — any transaction id not in the seen set is new → candidate. Seen set capped at 500 entries (memory-leak guard).
- Match — amount equal (± tolerance, default 0), optionally time-bounded by order creation.
- 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-gateawaygenerates a uniquetrx_idper 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/QrisMerchantIDdedupe 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 viagetGoPayWatcher()); embed it in any bot/backend. History falls back analytics → journal automatically. [S2]QrisMerchantID— a research SDK. FacadeGoPayMerchantwith granular services (auth,users,merchants,transactions,payouts,watch()), typed errors (ApiExceptioncarrieshttp_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
getHistoryreturning 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]