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
| Role | Responsibility |
|---|---|
| Game | Game client script; calls the sud.* APIs and only relays the signature artifacts |
| SUD OP | OpenPaaS runtime; brings up the platform payment UI |
| Game SVR | Game server; creates the business order and the three signature artifacts, receives delivery webhooks, and handles deduction and refund |
| SUD OP SVR | OpenPaaS 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
- The three signature artifacts must be produced by Game SVR. The client takes no part in assembling
signDataor computing signatures, and never touchespay_sign_keyorsession_key. - The
successcallback 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. - 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.
- 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.
- 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.
- 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.
- 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).
| Aspect | Option A: client polling | Option B: server push |
|---|---|---|
| Latency | Bounded by the interval steps; about 59 seconds in the worst case | Near real time |
| Request volume | Up to 11 queries per order | Reuses an existing persistent connection; no extra queries |
| Prerequisites | Only an order status query endpoint | A persistent connection channel with reconnection |
| Reliability | Client-initiated, so it naturally reaches a timeout terminal state | Depends on the connection being available; a fallback query is mandatory |
| Implementation cost | Low | Higher |
| Best suited to | No persistent connection, low payment frequency | An 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.
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:
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.
| Item | Value |
|---|---|
| Interval steps (ms) | 1000, 2000, 3000, 5000, 8000, 8000, 8000, 8000, 8000, 8000 |
| Number of queries | 11 (the first runs immediately, the rest after each step) |
| Total wait | About 59 seconds |
| Terminal states | Delivered / closed / timeout |
| Balance refresh after delivery | Up 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.
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
- 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.
- 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. - 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.
- A push does not mean the balance has refreshed. Still call
refreshBalanceWithRetry(). - 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:
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.
