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

18 KiB
Raw Permalink Blame History

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
gopay
gobiz
qris
emvco
payment-gateway
api-replay
mechanism
true 2026-10-15 2026-10-15

Analysis — GoPay Merchant QRIS gateways

Part of gopay-merchant-qris-gateway/index

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]:

  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 §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 §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.]