Gaming top-up flow
Take a customer from "I want 325 UC for PUBG Mobile" to a confirmed top-up - the full walkthrough
A customer is waiting on the other side of a form. They've tapped their game, picked a pack, and entered their player ID. From here it's six requests — three to figure out what to charge, one to take their money, and a couple to make sure it landed.
This guide is the full path. Sandbox-safe, copy-pastable, and honest about where the rough edges still are.
What this guide builds
A working purchase flow
Product picker, variant grid, charges preview, order placement, terminal-status loop.
Recovery for failed orders
User-fixable failures, wallet refunds, and the retry-vs-give-up call.
The map
┌──────────────────┐
│ 1. Find product │ GET /api/v1/topups/products?type=GAMING
└─────────┬────────┘
▼
┌──────────────────┐
│ 2. Read inputs │ GET /api/v1/topups/products/:id
└─────────┬────────┘
▼
┌──────────────────┐ (optional)
│ 3. Show variants │ GET /api/v1/topups/products/:id/variants
└─────────┬────────┘
▼
┌──────────────────┐
│ 4. Quote price │ POST /api/v1/topups/charges
└─────────┬────────┘
▼
┌──────────────────┐
│ 5. Place order │ POST /api/v1/topups/orders
└─────────┬────────┘
▼
┌──────────────────┐
│ 6. Watch status │ GET /api/v1/topups/orders/:id (loop)
└──────────────────┘Steps 3 and 4 are independent — fetch variants if the UI shows packs, or skip straight to charges if it accepts a raw amount.
Step 1 — Find the product
Start by asking the catalogue for the game the customer wants. type=GAMING filters out mobile recharges and utility products.
curl "$HOST/api/v1/topups/products?type=GAMING&search=pubg" \
-H "Authorization: Bearer $TOKEN"Each entry comes with an id — that's the handle threaded through every other request.
Cache the catalogue
Top-up products change rarely (new games launch, regional pricing shifts). A 5–15 minute cache on filtered product lists keeps the UI snappy and the rate budget healthy.
Step 2 — Read the required inputs
Different games ask for different things. PUBG Mobile wants a player ID and a server. Free Fire wants a player ID. Genshin wants UID plus region. Don't hardcode these — the product tells you.
curl "$HOST/api/v1/topups/products/4218" \
-H "Authorization: Bearer $TOKEN"The detail response includes input_fields[]. Render those as form inputs and respect the metadata:
| Field metadata | What it does |
|---|---|
is_required: true | Don't submit without it. |
field_type | Drives the input control: text, number, phone, or select. |
validation_regex | Client-side validation. Server re-validates. |
options[] | For select types — the only acceptable values. |
Three rules for input_data
The order request will carry input_data — a map keyed by the field_name from the input field. Three rules keep the integration out of trouble:
- Send only declared keys. Extras get rejected — fulfilment paths sometimes fold them into request signing and an unrecognised key would surface later as a confusing "Invalid Signature" error, so Octopus Cards stops it at the door.
- Always send required keys. Validation runs server-side; missing keys come back as a 400 with the human-readable label.
- Echo
selectvalues exactly. No fuzzy matching."asia"is not"Asia".
Step 3 — Show variants (optional)
If the UI shows packs ("60 UC for $0.99", "325 UC for $4.99"), this is where to fetch them.
curl "$HOST/api/v1/topups/products/4218/variants" \
-H "Authorization: Bearer $TOKEN"Each variant has fixed_amounts[] (the available denominations as decimal strings) and a fields[] array with par_value, delivery_time, etc. for display.
Variants don't need a variant_id
Octopus Cards picks the best variant for the order from product_id + amount (it auto-selects the one with the highest discount). Listing variants is purely for rendering — not for routing the order.
Skip this step entirely when the UI just asks the customer for an amount and the business model is "any amount, any time".
Step 4 — Quote the price
Before debiting, get the real number. The charges endpoint resolves the variant, applies the client discount, and returns exactly what will leave the wallet.
curl -X POST "$HOST/api/v1/topups/charges" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": 4218,
"amount": 4.99
}'The response has total_payable — that's what gets debited. For bulk flows, pre-check the wallet against it:
c, _ := quote(host, token, 4218, 4.99)
w, _ := getWallet(host, token, walletID)
if w.Amount < c.TotalPayable {
return errors.New("insufficient funds")
}When to call it
- Right before an order, every time. The result is server-cached for 5 minutes per
(product, amount, quantity, client)tuple, so this isn't a wasted call. - When the customer changes the amount in the UI. Re-quote so the displayed price stays accurate.
- Not on every keystroke. Debounce.
Step 5 — Place the order
Here's where the wallet gets debited and the order leaves the building.
curl -X POST "$HOST/api/v1/topups/orders" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": 4218,
"amount": 4.99,
"input_data": {
"player_id": "5123456789",
"server": "asia"
},
"client_reference": "CAMPAIGN_DEC_24"
}'Three immediate outcomes
The order endpoint always responds quickly — but the meaning of that response depends on how fulfilment resolved.
| HTTP | status | What it means | Next |
|---|---|---|---|
200 | PENDING | Typical. Order accepted, fulfilment in progress. | Move to step 6 — the polling loop. |
200 | DELIVERED | Fulfilment completed inside the request. | Done. Show success. |
200 | FAILED with is_user_fixable: true | Rejected (wrong player ID, suspended account). | Surface failure_reason. Wallet auto-refunded. Let them retry. |
400 | n/a | Validation, balance, or voucher errors. | Fix the request and retry. |
The wallet is debited synchronously
Even for PENDING orders, the wallet has already been charged. Only fulfilment is pending, not the payment. Failed orders auto-refund — but the debit and the refund are two distinct events, both visible in the wallet transaction log.
Step 6 — Watch the status
For PENDING orders, loop the detail endpoint until it terminates — or until enough time has passed to switch from foreground polling to a notification-based wait.
func waitForTerminal(host, token string, orderID uint64) (*orderResp, error) {
deadline := time.Now().Add(5 * time.Minute)
delay := 5 * time.Second
for time.Now().Before(deadline) {
url := fmt.Sprintf("%s/api/v1/topups/orders/%d", host, orderID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
var out orderResp
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
switch out.Status {
case "DELIVERED", "FAILED", "CANCELLED":
return &out, nil
}
time.Sleep(delay)
if delay < 30*time.Second {
delay *= 2
}
}
return nil, errors.New("order did not reach terminal within window")
}GET /topups/orders/:id refreshes the status against fulfilment on every call while the order is still PENDING — so a single hit returns the freshest known state. An order still PENDING after several minutes is Octopus Cards automatically retrying transient conditions, not a stuck order.
Recommended cadence
| Time since order | Poll every | What's usually happening |
|---|---|---|
| 0–60 seconds | 5–10 seconds | Most top-ups complete in this window. |
| 1–5 minutes | 30–60 seconds | Slow fulfilment day. Octopus Cards is auto-retrying transient conditions in the background. |
| >5 minutes | Stop foreground polling. | The order is not stuck — Octopus Cards keeps retrying until delivery or a clear customer-actionable failure. Register a notification email via PATCH /topups/orders/:id/notification-email and surface a "we'll email you when this completes" state. |
Handling failure
Failed orders aren't all the same. The is_user_fixable flag tells whether to invite the customer to retry.
is_user_fixable | What's wrong | Next |
|---|---|---|
true | Customer input is bad (wrong player ID, suspended account, plan ineligible). | Show failure_reason. Their wallet is already refunded. Let them re-enter input_data and place a fresh order — they'll get a brand-new reference. |
false | Octopus Cards exhausted its automatic recovery without classifying the failure. Rare — most non-user-fixable transient conditions keep the order in PENDING and never terminate as FAILED. | Surface "we couldn't complete this order, please try again or contact support". Wallet is refunded. |
false and stuck >5 min | Transient fulfilment issue; not a customer-side problem. | Surface "we're processing your order" — Octopus Cards keeps retrying until it delivers or hits a customer-actionable failure. |
Don't auto-retry on user-fixable failures
When is_user_fixable: true, retrying with the same input_data will fail the same way. The customer is the one who needs to change something — code can't fix it.
A note on idempotency
The client_reference field on POST /topups/orders is the idempotency key. Pass a client-supplied value (printable ASCII, ≤ 255 chars) and a duplicate submission returns 400 "Duplicate client_reference" — retries can't accidentally create a second order. Omit it and you lose dedup protection — retries without a client_reference will create duplicates.
To stay safe on network retries:
- Always send a client-supplied
client_referencefor any operation that might be retried. Generate it before the first attempt and reuse the same value on subsequent retries. - A
2xxresponse is final. A200means the order was placed and the wallet was debited. Never retry a2xx. - For
5xxand network errors, retry with the sameclient_reference— duplicates are rejected, so a second2xxcreate is impossible.
Putting it together
A full reference implementation that handles the immediate-terminal and async-pending paths in one function:
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
func purchase(host, token string, productID uint64, amount float64, playerID, server, clientRef string) (*orderResp, error) {
order, err := placeOrder(host, token, productID, amount, playerID, server, clientRef)
if err != nil {
return nil, err
}
// Already terminal? Done.
switch order.Status {
case "DELIVERED", "FAILED", "CANCELLED":
return order, nil
}
// Otherwise poll until it terminates.
return waitForTerminal(host, token, order.ID)
}What to read next
Mobile recharge flow
Same vertical, different shape - operator lookup, MSISDN handling, country-specific gotchas.
Create Order reference
Every field, every status, every error tab - the exhaustive spec.
Sync vs async
Why top-ups are async-by-default and how that differs from vouchers.
Webhook plans
Where outbound topup events are headed - and what to do until they ship.
Recap
- One product → one or many variants. The order request passes
product_id + amount; Octopus Cards picks the variant. input_datakeys must match the product's declaredinput_fieldsexactly — no extras, no guesses.- The wallet is debited synchronously; fulfilment is async.
- Poll
GET /topups/orders/:idfor live status — it refreshes the latest state on each call. - Failed orders auto-refund.
is_user_fixableis the signal for whether to invite the customer to retry.