GuidesWalkthroughs

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 metadataWhat it does
is_required: trueDon't submit without it.
field_typeDrives the input control: text, number, phone, or select.
validation_regexClient-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:

  1. 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.
  2. Always send required keys. Validation runs server-side; missing keys come back as a 400 with the human-readable label.
  3. Echo select values 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.

HTTPstatusWhat it meansNext
200PENDINGTypical. Order accepted, fulfilment in progress.Move to step 6 — the polling loop.
200DELIVEREDFulfilment completed inside the request.Done. Show success.
200FAILED with is_user_fixable: trueRejected (wrong player ID, suspended account).Surface failure_reason. Wallet auto-refunded. Let them retry.
400n/aValidation, 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.

Time since orderPoll everyWhat's usually happening
0–60 seconds5–10 secondsMost top-ups complete in this window.
1–5 minutes30–60 secondsSlow fulfilment day. Octopus Cards is auto-retrying transient conditions in the background.
>5 minutesStop 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_fixableWhat's wrongNext
trueCustomer 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.
falseOctopus 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 minTransient 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_reference for any operation that might be retried. Generate it before the first attempt and reuse the same value on subsequent retries.
  • A 2xx response is final. A 200 means the order was placed and the wallet was debited. Never retry a 2xx.
  • For 5xx and network errors, retry with the same client_reference — duplicates are rejected, so a second 2xx create 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)
}

Recap

  • One product → one or many variants. The order request passes product_id + amount; Octopus Cards picks the variant.
  • input_data keys must match the product's declared input_fields exactly — no extras, no guesses.
  • The wallet is debited synchronously; fulfilment is async.
  • Poll GET /topups/orders/:id for live status — it refreshes the latest state on each call.
  • Failed orders auto-refund. is_user_fixable is the signal for whether to invite the customer to retry.

On this page