Skip to content

Payment Flow ​

This page describes the complete path from placing an order to confirming delivery, together with orchestration code you can copy into your game script. For the parameters and return values of each API, see the API overview.

Roles ​

RoleResponsibility
GameGame client script; calls the sud.* APIs and only relays the signature artifacts
SUD OPOpenPaaS runtime; brings up the platform payment UI
Game SVRGame server; creates the business order and the three signature artifacts, receives delivery webhooks, and handles deduction and refund
SUD OP SVROpenPaaS server; confirms the real funding state and issues delivery or close instructions

Complete Flow ​

Phases 1 to 3 are independent of the confirmation mechanism; the two options differ only in Phase 4.

Key Constraints ​

  1. The three signature artifacts must be produced by Game SVR. The client takes no part in assembling signData or computing signatures, and never touches pay_sign_key or session_key.
  2. The success callback does not mean the goods were delivered; it only means the client-side payment flow ended. Never grant assets based on it. Delivery is driven by the webhook Game SVR receives.
  3. Business order numbers must be globally unique and delivery must be idempotent. Webhooks may arrive more than once because of network retries. Deliver a given order at most once, and still return success for repeated requests.
  4. Do not start the same order concurrently. Block new order requests while a previous one is still awaiting confirmation; otherwise several pending orders coexist and you cannot tell which one was delivered.
  5. Delivery and reading the balance are two separate things. The balance may lag briefly after the server confirms delivery, so retry a failed balance query instead of treating it as a delivery failure.
  6. Delivery confirmation must be cancellable. Stop polling or ignore late pushes when the page is deactivated, the scene changes, or the user leaves, so nothing writes into a destroyed UI.
  7. Whether you poll or receive pushes, the client must handle results idempotently by business order number. A repeated query result or a repeated push may trigger delivery handling only once.

Delivery Confirmation Options ​

Phase 4 has two implementations. They are interchangeable, and can also be combined (push as the primary path, polling as the fallback).

AspectOption A: client pollingOption B: server push
LatencyBounded by the interval steps; about 59 seconds in the worst caseNear real time
Request volumeUp to 11 queries per orderReuses an existing persistent connection; no extra queries
PrerequisitesOnly an order status query endpointA persistent connection channel with reconnection
ReliabilityClient-initiated, so it naturally reaches a timeout terminal stateDepends on the connection being available; a fallback query is mandatory
Implementation costLowHigher
Best suited toNo persistent connection, low payment frequencyAn existing persistent connection, delivery latency matters

The OpenPaaS SDK does not provide a push callback for order status. The sud object exposes no payment or order events, and the success callback of sud.requestVirtualPayment covers only the client-side payment flow. Push therefore has to travel over the game's own persistent connection channel, for example WebSocket.

The orchestration code below uses Option A as the example. Option B replaces the delivery trigger on top of it and reuses the same query logic as a fallback.

API Wrappers ​

Both APIs are callback-based, so wrap them in Promises first and then orchestrate them. createOrder and queryOrder are your own game server endpoints; replace them with your backend implementation. They are not provided by OpenPaaS, and the order status values follow your own backend contract.

javascript
const RETRY_DELAYS_MS = [1000, 2000, 3000, 5000, 8000, 8000, 8000, 8000, 8000, 8000];

function requestVirtualPayment(order) {
    return new Promise((resolve, reject) => {
        sud.requestVirtualPayment({
            signData: order.signData,
            paySig: order.paySig,
            signature: order.signature,
            success: (res) => resolve(res),
            fail: (err) => reject(err),
            complete: () => {},
        });
    });
}

function getBalance(env) {
    return new Promise((resolve, reject) => {
        sud.getBalance({
            env,
            success: (res) => resolve(res),
            fail: (err) => reject(err),
            complete: () => {},
        });
    });
}

// Refresh the balance after delivery: handled separately from order confirmation,
// because a failure here only affects the display, not the fact of delivery
async function refreshBalanceWithRetry(env, maxAttempts) {
    for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
        try {
            return await getBalance(env);
        } catch (err) {
            console.error('Failed to query balance', err.errCode, err.errMsg);
            if (attempt < maxAttempts - 1) await wait(1000);
        }
    }
    return null;
}

function wait(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

The purchase entry point is the same for both options; only the last step differs:

javascript
let pendingOrderId = '';

async function purchase(productId, quantity, attach) {
    if (pendingOrderId) {
        console.log('A previous order is still awaiting confirmation; please wait');
        return;
    }

    const order = await createOrder(productId, quantity, attach);
    if (!order) {
        console.error('Failed to create the order');
        return;
    }

    try {
        const payment = await requestVirtualPayment(order);
        console.log('Client payment flow finished', payment.sudTradeNo);
    } catch (err) {
        console.error('Payment failed or was cancelled', err.errCode, err.errMsg);
        return;
    }

    // success does not mean delivered; keep confirming the order status
    // order.orderId is the business order number returned by your Game SVR order endpoint
    pendingOrderId = order.orderId;

    // Option A: start polling immediately
    confirmOrder(order.orderId);

    // Option B: do not poll; wait for the push over the persistent connection
    // and only run a fallback query after reconnecting
}

Option A: Client Polling ​

Polling Parameters ​

The values below can be adopted directly. The intervals start dense and grow sparse, balancing perceived delivery speed against request volume.

ItemValue
Interval steps (ms)1000, 2000, 3000, 5000, 8000, 8000, 8000, 8000, 8000, 8000
Number of queries11 (the first runs immediately, the rest after each step)
Total waitAbout 59 seconds
Terminal statesDelivered / closed / timeout
Balance refresh after deliveryUp to 3 attempts, 1000 ms apart

Polling Orchestration ​

Polling needs an invalidatable poll identifier. Increment it each time polling starts and check at every step that it is still the latest; incrementing it once when the page is deactivated stops all in-flight polling.

javascript
let pollId = 0;

// Call when the page is deactivated or the scene changes, to stop in-flight polling
function deactivatePaymentPolling() {
    pollId += 1;
}

// Call when the page becomes visible again, to resume an order that is still unconfirmed
function resumePendingOrder() {
    if (pendingOrderId) confirmOrder(pendingOrderId);
}

async function confirmOrder(orderId) {
    const currentPollId = ++pollId;

    for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
        if (!isPollActive(currentPollId)) return;

        const order = await queryOrder(orderId);
        if (!isPollActive(currentPollId)) return;

        if (isTerminalStatus(order)) {
            pendingOrderId = '';
            await handleOrderSettled(order.status);
            return;
        }

        if (attempt === RETRY_DELAYS_MS.length) break;
        await wait(RETRY_DELAYS_MS[attempt]);
    }

    pendingOrderId = '';
    console.log('Delivery confirmation timed out; query the order status manually later');
}

function isPollActive(currentPollId) {
    return currentPollId === pollId && isPageVisible();
}

Implement isPageVisible() and queryOrder() for your own engine and backend.

Option B: Server Push ​

Push Constraints ​

  1. Pushes arrive more than once. Reconnection and server-side retries can both deliver the terminal state of the same order repeatedly. The client must handle them idempotently by business order number and trigger delivery handling only once per order.
  2. Push is unreliable, so a fallback is mandatory. Pushes are lost when the connection is not established, the game is backgrounded, or the process is reclaimed. After reconnecting, query any order that is still unconfirmed — that is, reuse confirmOrder() from Option A.
  3. Do not send sensitive data in plaintext over the push channel. The push carries only the terminal state and the business order number; sensitive fields still go through the authenticated Game SVR endpoints.
  4. A push does not mean the balance has refreshed. Still call refreshBalanceWithRetry().
  5. Late pushes must be ignorable. A push that arrives after the page is deactivated must not write to the UI.

Push Orchestration ​

Delivery handling and terminal-state detection are the same for both options, so extract them and share them between push and polling:

javascript
function isTerminalStatus(order) {
    return !!order && (order.status === 'DELIVERED' || order.status === 'CLOSED');
}

async function handleOrderSettled(status) {
    if (status === 'DELIVERED') {
        const balance = await refreshBalanceWithRetry(0, 3);
        if (balance) {
            console.log('Delivered, total balance', balance.balance, 'granted', balance.presentBalance);
        } else {
            console.log('Order delivered, but the balance refresh failed; query it manually later');
        }
        return;
    }

    console.log('Order closed without delivery');
}

// Business order numbers whose terminal state has been handled, used for push idempotency
const settledOrderIds = new Set();

function onOrderStatusPush(message) {
    // Ignore pushes that arrive after the page is deactivated
    if (!isPageVisible()) return;

    const orderId = message.orderId;
    const status = message.status;

    // Pushes may arrive repeatedly; handle them idempotently by business order number
    if (settledOrderIds.has(orderId)) return;
    if (status !== 'DELIVERED' && status !== 'CLOSED') return;
    settledOrderIds.add(orderId);

    if (pendingOrderId === orderId) pendingOrderId = '';
    handleOrderSettled(status);
}

// Fallback: after the persistent connection is restored, query any order still unconfirmed
function onConnectionRestored() {
    if (pendingOrderId) confirmOrder(pendingOrderId);
}

Hook onOrderStatusPush into your own persistent-connection message dispatch; the field names in message follow your backend contract. If you enable both options, confirmOrder() should also add to settledOrderIds, so polling and push do not each handle the same order once.

Every step can fail. Handle errors step by step and stop instead of continuing; do not catch everything at the end and treat it as a successful payment.