| name | nuwax-pay |
| description | Payment integration API for projects. Provides three modes: 1) Cashier mode — hosted cashier URL; 2) H5 mode — custom UI in mobile/system browser (NOT in App WebView); 3) App native mode — JSBridge + native SDK inside App WebView only. Also supports querying payment status with channel sync. Use this skill whenever the user wants to add payment/checkout/purchase functionality to a project. Keywords: payment, pay, cashier, checkout, H5 pay, App pay, native pay, WeChat pay, Alipay pay, order, 支付, 收银台, App支付, 原生支付, 下单, 微信支付, 支付宝支付. |
| license | MIT |
Nuwax Pay Skill
Overview
This skill (@nuwax-pay) provides REST endpoints to integrate payment into your projects. The frontend must pass projectId (sourced from the DEV_PROJECT_ID env var — see Frontend pitfalls for how to get it into the browser) plus the order details and amount. No login/tenant info is needed in the request body — the browser session identifies the tenant.
Use this skill when the user's project needs to collect money. Pick the integration mode by client context (pitfall #7), then use status query to confirm payment:
| Mode | When to use | Client | Effort |
|---|
Cashier mode (/api/pay/general/cashier) | Default choice. Hosted cashier page. iframe dev: window.open + poll in place. prod: location.href + return URL. | Any (App / browser / desktop) | Lowest — one API call + open cashier. |
App native mode (/app/create-order → /app/pay) | Only inside App WebView. WxPay: redirectUrl + launchMiniProgram; AliPay: open redirectUrl. Poll /status. | App WebView only — backend rejects non-App callers (9384) | Medium — two API calls + invokeAppPay. |
H5 mode (/h5/create-order → /h5/pay) | Custom payment UI in mobile/system browser (Safari, Chrome, WeChat built-in browser is separate). Two-step: create order, then invoke channel. | Mobile/system browser only — never in App WebView (backend rejects with 9383) | Medium — two API calls + handle formHtml/redirectUrl. |
Status query (/api/pay/general/status) | After payment, poll the order status (all modes). | Any | One API call. |
Client routing (MANDATORY — pitfall #7): App WebView → App API only (/app/*). Phone/system browser → H5 API only (/h5/*) or cashier. Never call H5 from App or App from browser — the backend enforces this.
Order numbers: the backend generates business order numbers based on projectId (required, from DEV_PROJECT_ID):
- If you pass
bizOrderNo: GP1_{projectId}_{bizOrderNo} — idempotent, safe for retries.
- If you omit
bizOrderNo: GP2_{projectId}_{timestamp}_{random} — auto-generated, always unique.
Payment is asynchronous — status polling is mandatory. The user pays on the cashier/channel page, which is out of your control. When the browser returns to your result page (via frontNotifyUrl), the payment result may not be in the backend yet (and the user may not return at all). You MUST poll POST /api/pay/general/status on the return page until status === "PAID" (or FAILED/CLOSED). Polling /status is the only reliable source of truth for payment success — never trust that "the user came back" means "paid".
Return URL behavior by mode
| Context | Open cashier | Wait for result | frontNotifyUrl role |
|---|
Dev iframe preview (window.self !== window.top) | window.open(cashierUrl) — never location.href in iframe | Stay on pay page, poll /status until terminal; show result in place | Passed to API for the popup window's post-pay redirect only — the iframe page does not rely on this URL coming back |
| Standalone / published prod | window.location.href = cashierUrl | Cashier redirects to frontNotifyUrl → result page polls /status | Required — drives the return-redirect flow (pitfall #2) |
This split is mandatory (pitfall #6). Using location.href inside a dev iframe causes cross-origin cashier round-trips → blank page / lost order. The iframe path avoids leaving the iframe entirely.
Both modes end with gatewayOrderNo from storage, not from the return URL (pitfall #3). H5 relay behavior unchanged — see H5 section for iframe vs standalone channel launch.
🚨 Implementation iron rules — read these BEFORE writing a single line of payment code
These are the seven things that, when skipped or implemented loosely, are responsible for every "支付成功后跳转空白页 / 订单丢失 / 重复写入 / App 内 H5 违规" bug this skill has seen. Treat them as non-negotiable. Do NOT try to be clever and shortcut them.
projectId first, verified by grep. Create .env with DEV_PROJECT_ID → loadEnv + define __APP_PROJECT_ID__ in vite.config.ts → pnpm build && grep -c "$DEV_PROJECT_ID" dist/assets/*.js must return ≥ 1. A green build with empty projectId is NOT done. (pitfall #1)
frontNotifyUrl carries NO # and NO gatewayOrderNo. Just origin + pathname + '?from=pay-result'. Do not "helpfully" append the order number to the URL — see the FAQ in pitfall #2 for why that is a trap. (pitfall #2)
- Guard rewrites the return URL in MODULE SCOPE, before the router is constructed — never in
useEffect/onMounted. (pitfall #2)
- Recover
gatewayOrderNo as an OBJECT from storage, not as a field. The single most common self-inflicted bug is let s = obj?.gatewayOrderNo ?? fallback() — the ?? collapses s to a string and s?.gatewayOrderNo is then forever undefined → blank result page. Copy the ❌/✅ block in pitfall #3 verbatim. (pitfall #3)
- Two-path checkout (MANDATORY). Dev iframe preview →
window.open(cashierUrl) + poll /status on the unchanged pay page, show result in place, clearPendingOrder on PAID. Standalone/prod → window.location.href = cashierUrl + return via frontNotifyUrl. Never location.href to cashier inside an iframe. (pitfall #6)
- Shape A self-check is a HARD GATE (standalone path). Simulate
?from=pay-result with order pre-stashed — see Step 10 Path B. Iframe path self-check: click Pay in dev preview → popup opens → pay page polls /status and shows result without iframe navigation. (Step 10)
- App vs H5 routing (MANDATORY). Detect App WebView (
X-Client-Type or UA) → call /app/create-order + /app/pay only. Mobile/system browser → /h5/* or cashier only. All pay API calls must use payFetch so X-Client-Type matches backend guards (9383/9384). (pitfall #7)
If you skip any one of these, expect a blank page, a lost/dupe record, or a channel violation in production. The pitfalls below explain the why; this block is the what to do. Do not proceed to writing UI until you can tick all seven.
Frontend pitfalls — READ FIRST
These are the five integration traps that cause real production bugs. Read this section before writing any payment code. Each one was hit and fixed in a real project; skipping them produces silent failures (callback page never opens, duplicate records, blank page on /prod).
1. projectId must be injected at build time — and you MUST create a .env file first
⚠️ This is the #1 most common failure. If you skip the .env file creation below, projectId will be an empty string in the browser, and the payment API will reject every request with "projectId must not be blank".
There are two layers to understand:
Layer 1 — Browser cannot read env vars. process.env.DEV_PROJECT_ID works in Node/agent scripts, not in frontend JS running in the user's browser. Vite only statically replaces specific import.meta.env.VITE_* / defined vars at build time; an arbitrary process.env.DEV_PROJECT_ID resolves to undefined (empty string) in the browser → empty projectId → API rejects the order.
This differs from skills that run as agent-side scripts (e.g. datatable-for-page-api, which reads os.environ.get('DEV_PROJECT_ID') in a Python script and works fine). For frontend browser code you must inject it at build time.
Layer 2 — Platform Vite process does NOT inherit the agent's env vars. The agent shell has DEV_PROJECT_ID set. But the platform starts the Vite dev server / build as a separate child process that does not inherit this variable. So process.env.DEV_PROJECT_ID is undefined inside the Vite process. The only reliable data source is a .env file in the project root, which Vite's loadEnv() reads directly from disk.
Step-by-step (MUST follow all 3 steps):
Step 1 — Create .env file (MANDATORY, do this FIRST):
The agent MUST create a .env file in the project root, writing the value from the agent's own $DEV_PROJECT_ID:
echo "DEV_PROJECT_ID=$DEV_PROJECT_ID" > .env
Without this file, loadEnv() finds nothing and process.env in the Vite process is empty — the || fallback chain below collapses to ''. This is not optional. If .env is missing, payment will silently fail.
Step 2 — Configure vite.config.ts to inject via define:
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const projectId =
env.DEV_PROJECT_ID ||
process.env.DEV_PROJECT_ID ||
'';
return {
base: './',
define: {
__APP_PROJECT_ID__: JSON.stringify(projectId),
},
};
});
Step 3 — Verify projectId was actually injected (MANDATORY gate):
After configuring, the agent MUST verify the value made it into the build output. This is a hard success criterion — if verification fails, the integration is NOT complete.
pnpm build
grep -c "$DEV_PROJECT_ID" dist/assets/*.js
⚠️ Do NOT skip Step 3. A build that compiles successfully can still have an empty projectId (the define happily replaces with ""). The grep check is the only way to catch this before runtime.
Then in frontend code, read the constant:
declare const __APP_PROJECT_ID__: string;
export const PROJECT_ID = typeof __APP_PROJECT_ID__ !== 'undefined' ? __APP_PROJECT_ID__ : '';
Do NOT write projectId: process.env.DEV_PROJECT_ID in frontend fetch bodies. Do NOT use import.meta.env.DEV_PROJECT_ID unless you have explicitly prefixed your env var as VITE_DEV_PROJECT_ID and exposed it. The define + global constant pattern above is the reliable one.
2. frontNotifyUrl MUST NOT contain a # hash fragment — and the return guard MUST run BEFORE the router reads the hash
⚠️ Neither the cashier nor the H5 backend relay appends payment params to your final URL.
- Cashier mode: verified against the hosted cashier source —
redirectToMerchantAfterPaid() does window.location.href = ctx.bizRedirectUrl using your frontNotifyUrl *verbatim*. It does NOT append orderNo, gatewayOrderNo, status, or any other payment param.
- H5 mode: WeChat/Alipay redirect to
/api/pay/general/h5/front-notify?returnUrl=... first; that endpoint validates returnUrl and 302s to your frontNotifyUrl as-is — it does NOT append payment params either. Any channel query params on the relay request are discarded.
Earlier versions of this skill assumed the channel appends payment params on return — that assumption is WRONG and is the root cause of "lost order / blank page" bugs. The only thing the browser sees on the result page is exactly the frontNotifyUrl you set (e.g. https://host/page/{id}/?from=pay-result). Plan your recovery strategy around that fact: the order handle must come from YOUR storage, not from the return URL.
Most SPA projects on this platform use hash routing (/#/some-page). You still MUST NOT put a # in frontNotifyUrl: createHashRouter reads window.location.hash at construction time, and a return URL that carries no hash (which it never does — you build frontNotifyUrl without one) makes the router force in a #/ and boot on the wrong route → blank page. The job of the return guard below is to rewrite that no-hash URL into #/pay-result before the router reads it.
Rule: frontNotifyUrl should be a plain URL without a hash, carrying only a query flag (e.g. ?from=pay-result). Then a top-level return guard rewrites the URL into the hash route.
const frontNotifyUrl =
window.location.origin + window.location.pathname + '?from=pay-result';
⚠️ CRITICAL — timing: the guard MUST run BEFORE the hash router is created, not in a useEffect/onMounted.
This is the single most common cause of the blank return page. The guard's job is to fix window.location.hash so the router boots on the right route. But:
- A hash router (
createHashRouter / createRouter({ history: createWebHashHistory() })) reads and initializes window.location.hash at the moment it is constructed. If the URL has no hash yet, the router forces one in (usually #/) and boots from there.
useEffect (React) and onMounted (Vue) run after the component mounts — by which point the router module has already been imported and constructed. The router has already read the empty/wrong hash. Rewriting the URL afterwards is too late: history.replaceState does not fire a hashchange, so the router never re-parses → it stays on the wrong route → blank page.
Therefore the rewrite must happen synchronously, in module scope, before the router is constructed (an IIFE that runs at module-eval time, before the createHashRouter(...) call on the next lines). Do NOT put it in a hook/effect. Do NOT import a pre-built router object and then try to fix the URL in App's body — the import already constructed it.
import { createHashRouter, RouterProvider } from 'react-router-dom';
import { ROUTES } from './router';
const PAY_FLAG = 'from';
const PAY_FLAG_VALUE = 'pay-result';
const PAY_PARAMS = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt'];
function isPayReturn(sp: URLSearchParams): boolean {
if (sp.get(PAY_FLAG) === PAY_FLAG_VALUE) return true;
return PAY_PARAMS.some((k) => !!sp.get(k));
}
(function normalizePayReturnUrl() {
const url = new URL(window.location.href);
const hashSp = new URLSearchParams(url.hash.split('?')[1] ?? '');
const source =
isPayReturn(url.searchParams) ? url.searchParams
: isPayReturn(hashSp) ? hashSp
: null;
if (!source) return;
const pairs: string[] = [];
for (const key of PAY_PARAMS) {
const v = source.get(key);
if (v) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
source.forEach((value, key) => {
if (key === PAY_FLAG || PAY_PARAMS.includes(key)) return;
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
});
url.search = '';
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`;
window.history.replaceState(null, '', url.toString());
})();
const router = createHashRouter(ROUTES);
export default function App() {
return <RouterProvider router={router} />;
}
Why replaceState and not window.location.replace(...)? replace() triggers a full navigation/reload that races with module init and re-runs the IIFE. replaceState mutates the URL in place with no reload and no hashchange — exactly what we want, because the router (constructed on the very next line) then reads the already-correct hash.
import { createApp } from 'vue';
import { createRouter, createWebHashHistory } from 'vue-router';
import App from './App.vue';
const PAY_FLAG = 'from';
const PAY_FLAG_VALUE = 'pay-result';
const PAY_PARAMS = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt'];
function isPayReturn(sp) {
if (sp.get(PAY_FLAG) === PAY_FLAG_VALUE) return true;
return PAY_PARAMS.some((k) => !!sp.get(k));
}
;(function normalizePayReturnUrl() {
const url = new URL(window.location.href)
const hashSp = new URLSearchParams(url.hash.split('?')[1] ?? '')
const source = isPayReturn(url.searchParams) ? url.searchParams
: isPayReturn(hashSp) ? hashSp : null
if (!source) return
const pairs = []
for (const key of PAY_PARAMS) {
const v = source.get(key)
if (v) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
}
source.forEach((value, key) => {
if (key === PAY_FLAG || PAY_PARAMS.includes(key)) return
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
})
url.search = ''
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`
window.history.replaceState(null, '', url.toString())
})()
const router = createRouter({ history: createWebHashHistory(), routes: [] });
createApp(App).use(router).mount('#app');
For path-routed SPAs (BrowserRouter / createWebHistory), frontNotifyUrl can be the path directly (origin + '/pay-result') — no guard needed. Hash routing is the case that needs the flag + pre-router guard.
❓ FAQ — "Since the return URL never has the order number, why don't I just append gatewayOrderNo to frontNotifyUrl myself so it survives the redirect?"
You can build frontNotifyUrl = ... + '?from=pay-result&gatewayOrderNo=' + gw, but do not — it is unnecessary and harmful. The reliable recovery source for gatewayOrderNo is the blob you wrote to localStorage (pendingOrder:{gatewayOrderNo}) before the redirect, which is far more durable than a URL query (URLs get rewritten/stripped by proxies, channels, private mode; localStorage only disappears if the user actively clears cache). Putting the order number in the URL is therefore only a redundant copy of storage, not a replacement, and it adds two real risks:
- Exposure — the gateway order number sits in the address bar, viewable/screenshotable/editable by the user.
- Idempotency-key poisoning — an attacker can hand-craft
?gatewayOrderNo=<someone else's order> to try to trip the paid:{...} write. (/status truth-checks the real payment state, so this is contained, but it is needless attack surface.)
Conclusion: frontNotifyUrl carries ONLY the ?from=pay-result flag. The order handle comes from storage. This is intentional design — do not "helpfully" also put the order number in the URL. (The guard's PAY_PARAMS scan still picks up a param if a channel ever echoes one — that path is defensive-only, not something you build the URL around.)
3. Recover gatewayOrderNo from storage FIRST — the return URL does NOT echo it back
⚠️ Read this together with pitfall #2. Because both cashier and H5 relay return to frontNotifyUrl as-is (no params appended), the result URL normally contains no gatewayOrderNo at all. So gatewayOrderNo MUST be recovered primarily from storage you wrote before leaving for payment — the URL-query fallback is only a defensive extra for the day some channel does echo a param. Do not design the recovery around "the URL will have the order number" — it usually will not.
🛠 Two independent chains — both must work. Do not assume that because the guard (pitfall #2) landed the URL on #/pay-result, the result page is fine. The guard only fixes routing (URL → route match). Recovering the data (gatewayOrderNo → able to poll /status and write the record) is a separate chain (this pitfall). A paid order can land on the right route but still show "未找到订单 / blank" if this recovery chain is broken. The Shape A self-check (Step 10) verifies BOTH at once — that is why it is mandatory.
⚠️ THE most common self-inflicted bug — keep stashed as the OBJECT, read the field only at the very end. The recovery variable holds the parsed object ({ gatewayOrderNo, amount, ... }). If you write let s = obj?.gatewayOrderNo ?? fallback(), then when sessionStorage hits, the ?? collapses s to the order-number string, and every subsequent s?.gatewayOrderNo is forever undefined → recovery fails → paid order lands on a "未找到订单 / blank" page. Copy this block verbatim:
let stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
stashed = stashed?.gatewayOrderNo ?? recoverPendingFromLocalStorage();
const gatewayOrderNo = stashed?.gatewayOrderNo || '';
let stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
if (!stashed || typeof stashed !== 'object' || !stashed.gatewayOrderNo) {
stashed = recoverPendingFromLocalStorage();
}
const gatewayOrderNo =
stashed?.gatewayOrderNo ||
hq.get('gatewayOrderNo') || q.get('gatewayOrderNo') ||
hq.get('orderNo') || q.get('orderNo') || '';
There are three recovery sources, tried in this order:
-
sessionStorage (primary) — stash pendingOrder ({ gatewayOrderNo, amount, name, ... }) on the pay page before redirecting to the cashier. Same-origin, survives the redirect within the same tab.
-
localStorage (MANDATORY cross-origin fallback) — sessionStorage is not reliable across payment redirects: the cashier is a different origin (m10096.nuwax.com), and H5 channel pages are also outside your app. Some browsers / privacy modes / "return in a new tab" scenarios drop or isolate session storage. You MUST also persist the same pending-order blob to localStorage under a key that includes gatewayOrderNo (e.g. pendingOrder:{gatewayOrderNo}) on the pay page before redirecting to the cashier or invoking H5 pay. The result page scans those keys and recovers the most-recent one when sessionStorage is gone. Without this, a paid order returns to a "未找到订单 / failed" screen in exactly the cases that matter most (cross-origin, new tab). Clear the entry after a successful PAID write to avoid unbounded growth.
-
URL query (defensive fallback only) — scan location.search AND the hash query for gatewayOrderNo/orderNo. Usually empty (cashier and H5 relay append nothing), but harmless to check. Add useSearchParams() from your router as one more source if available.
const pending = { gatewayOrderNo, amountInFen, name, message };
const pendingJson = JSON.stringify(pending);
sessionStorage.setItem('pendingOrder', pendingJson);
localStorage.setItem(`pendingOrder:${gatewayOrderNo}`, pendingJson);
function resolveGatewayOrderNo() {
const stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
if (stashed?.gatewayOrderNo) return stashed.gatewayOrderNo;
const lsHit = recoverPendingFromLocalStorage();
if (lsHit?.gatewayOrderNo) return lsHit.gatewayOrderNo;
const q = new URLSearchParams(window.location.search);
const hq = new URLSearchParams((window.location.hash.split('?')[1]) || '');
return hq.get('gatewayOrderNo') || q.get('gatewayOrderNo')
|| hq.get('orderNo') || q.get('orderNo') || '';
}
function recoverPendingFromLocalStorage() {
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith('pendingOrder:')) continue;
try {
const o = JSON.parse(localStorage.getItem(key) || 'null');
if (o?.gatewayOrderNo) entries.push(o);
} catch { }
}
return entries.length ? entries[entries.length - 1] : null;
}
4. Sub-path deployment: keep /api/* absolute, set Vite base: './'
Projects deploy under a sub-path (dev: /page/{projectId}-xxxx/dev/, prod: /page/{projectId}-xxxx/prod/). Two rules that are easy to get backwards:
- Static assets (JS/CSS) → use a relative base. Set
base: './' in vite.config.ts so the bundle references ./assets/.... With the default base: '/', assets resolve to the domain root under a sub-path → 404 → blank page (the result page's JS won't even load).
- API calls
/api/pay/general/* → MUST stay absolute (/api/...). The API gateway is mounted at the domain root /api/, independent of the project's sub-path. Do not "relativize" the API path together with assets.
frontNotifyUrl should use window.location.origin + window.location.pathname (read at runtime) so it automatically follows whatever sub-path (/dev/ or /prod/) the user is actually on — never hardcode /dev or /prod.
5. Persisting a business record after PAID MUST be idempotent (by gatewayOrderNo)
The status poll fires onPaid once you hit PAID, but your business write (creating an order row, recording a tip, etc.) can still execute multiple times because:
- React
<StrictMode> runs useEffect twice in dev (mount → unmount → mount) — an in-memory let done = false flag resets on the second run → duplicate records.
- The user refreshes the result page; the flag is gone.
Always gate the business write with a persisted idempotency key keyed by gatewayOrderNo (localStorage/sessionStorage), not an in-memory variable. Reserve the slot before writing, roll it back if the write fails.
Also: never silently SKIP the write when sessionStorage is missing — fall back to the pay status. The onPaid callback receives info from /status, which contains orderAmount (and paidAt, payChannel). If the stashed pendingOrder is gone (new tab, refresh after close, private mode), the order is still paid and gatewayOrderNo was recovered from the URL (pitfall #3) — so you still have enough to write the record. Recover amount from info.orderAmount and use a sensible default (e.g. an "anonymous" name) for any business field you can't recover. The bug to avoid: wrapping the write in if (stashed) {...} so a missing stash turns a successful payment into a lost record.
const KEY = `paid:${gatewayOrderNo}`;
async function onPaid(info) {
if (localStorage.getItem(KEY) === '1') { setState('paid'); return; }
localStorage.setItem(KEY, '1');
try {
const stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null');
await createBusinessRecord({
gatewayOrderNo,
amount: stashed?.amount ?? info.orderAmount ?? 0,
name: stashed?.name ?? 'anonymous',
message: stashed?.message ?? '',
});
sessionStorage.removeItem('pendingOrder');
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`);
} catch (e) {
localStorage.removeItem(KEY);
console.error(e);
setState('paid'); setStatus('支付成功,但记录同步失败,请联系商家');
return;
}
setState('paid');
}
⚠️ Persist gatewayOrderNo into your BUSINESS DATA TABLE — not just browser storage. Browser storage (sessionStorage/localStorage) is only the recovery mechanism for the post-return page; it is NOT a record of the payment. The user clears their cache, switches devices, or a support agent looks up an order → that data is gone. The business record you write on PAID MUST include gatewayOrderNo as a dedicated column in your data table (e.g. a donations/orders table with a gateway_order_no column). This is the single source of truth for traceability — later you can resync any order by passing its stored gatewayOrderNo back into /status.
Write is half the job — expose the read side too. A column you INSERT but never SELECT is invisible: you can't show it, can't search by it, can't reconcile against it. When you design the data table and its SQL APIs (via the datatable-for-page-api skill), make sure EVERY read API (list/get/search) SELECTs gateway_order_no alongside the business fields, and the frontend model carries gatewayOrderNo end-to-end. The common bug: add writes the column, but getAll/search forget to SELECT it → the gateway order number is stored but unreachable for display or dispute resolution.
Why bother if you don't render it in the UI? Because the moment a customer says "I paid but it's not in the wall", gatewayOrderNo is the only handle that lets you look the payment up in /status, prove it paid, and decide whether to manually repair the record. No gatewayOrderNo in the table = no way to investigate. Always persist it, and always be able to read it back.
6. Dev iframe preview: window.open + in-place poll (never location.href)
⚠️ Mandatory for dev preview. Platform dev embeds your project in an iframe. If you window.location.href = cashierUrl inside the iframe, the iframe navigates cross-origin to the cashier and back — causing blank pages, lost storage, and broken layouts. The only supported dev-iframe pattern is:
系统预览壳(外层,不变)
└── iframe(项目页,始终不离开)
点支付 → stashPendingOrder → window.open(收银台) ← 新窗口
原页轮询 POST /api/pay/general/status
PAID → 原页展示结果 + clearPendingOrder
Standalone / published prod (window.self === window.top):
项目页 → stashPendingOrder → location.href = 收银台
收银台付完 → frontNotifyUrl 回跳 → 结果页轮询 /status
Rules (non-negotiable):
| Step | iframe dev preview | standalone / prod |
|---|
| Before pay | stashPendingOrder(...) | stashPendingOrder(...) |
| Open cashier | window.open(cashierUrl, '_blank', 'noopener,noreferrer') | window.location.href = cashierUrl |
| Wait for result | Poll /status on pay page (same component or inline UI) | Cashier returns to frontNotifyUrl → result page polls |
| On PAID | Show success in place + clearPendingOrder(gatewayOrderNo) + idempotent business write | Result page flow (pitfall #2–#5) + cleanup stash |
frontNotifyUrl | Still pass to /cashier API (popup redirects there after pay — iframe ignores it) | Drives return redirect (no #, pitfall #2) |
Copy-paste helpers:
export function isEmbeddedPreview() {
try { return window.self !== window.top; } catch { return true; }
}
export function buildFrontNotifyUrl() {
return window.location.origin + window.location.pathname + '?from=pay-result';
}
const PENDING_LATEST_KEY = 'pendingOrder:latest';
export function stashPendingOrder(pending) {
const json = JSON.stringify(pending);
const gw = pending.gatewayOrderNo;
localStorage.setItem(`pendingOrder:${gw}`, json);
localStorage.setItem(PENDING_LATEST_KEY, json);
try { sessionStorage.setItem('pendingOrder', json); } catch { }
}
export function clearPendingOrder(gatewayOrderNo) {
try { sessionStorage.removeItem('pendingOrder'); } catch { }
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`);
localStorage.removeItem(PENDING_LATEST_KEY);
}
export function openCashier(cashierUrl) {
if (isEmbeddedPreview()) {
const w = window.open(cashierUrl, '_blank', 'noopener,noreferrer');
if (!w) throw new Error('弹窗被拦截,请允许弹出窗口后重试');
return w;
}
window.location.href = cashierUrl;
return null;
}
export function pollUntilPaid(gatewayOrderNo, { onPaid, onFailed, intervalMs = 2500, timeoutMs = 60 * 60 * 1000 } = {}) {
let stop = false;
const start = Date.now();
(async () => {
while (!stop && Date.now() - start < timeoutMs) {
try {
const info = await queryStatus(gatewayOrderNo);
if (info.status === 'PAID') { onPaid?.(info); return; }
if (info.status === 'FAILED' || info.status === 'CLOSED') { onFailed?.(info); return; }
} catch { }
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!stop) onFailed?.({ status: 'CLOSED' });
})();
return () => { stop = true; };
}
iframe pay flow (cashier — copy verbatim):
async function payWithCashierInIframe({ amountInFen, subject, name, message, onPaid, onFailed }) {
const frontNotifyUrl = buildFrontNotifyUrl();
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({ amountInFen, subject, frontNotifyUrl });
const pending = { gatewayOrderNo, amountInFen, subject, name, message };
stashPendingOrder(pending);
openCashier(cashierUrl);
return pollUntilPaid(gatewayOrderNo, {
onPaid: async (info) => {
await writeBusinessRecordOnce(pending, info);
clearPendingOrder(gatewayOrderNo);
onPaid?.(info);
},
onFailed,
});
}
standalone pay flow (cashier):
async function payWithCashierStandalone({ amountInFen, subject, name, message }) {
const frontNotifyUrl = buildFrontNotifyUrl();
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({ amountInFen, subject, frontNotifyUrl });
stashPendingOrder({ gatewayOrderNo, amountInFen, subject, name, message });
window.location.href = cashierUrl;
}
Unified entry (use this in PayButton):
export async function payWithCashier(opts) {
if (isEmbeddedPreview()) return payWithCashierInIframe(opts);
return payWithCashierStandalone(opts);
}
❌ Never do this in iframe dev preview: window.location.href = cashierUrl — this is the #1 cause of post-pay blank iframe.
Popup blocked? Show a clear error asking the user to allow popups. Do not fall back to location.href inside iframe.
7. App WebView vs mobile browser — route to the correct API (never mix H5 and App)
⚠️ Channel policy: WeChat/Alipay H5 pay inside an App WebView is a violation — the backend rejects it with error 9383 (pay_h5_not_allowed_in_app). Conversely, App native pay from a system browser is rejected with 9384 (pay_app_native_requires_app). Your frontend MUST pick the API set before calling create-order.
⚠️ Backend reads the HTTP header X-Client-Type, not a JS global alone. App 壳注入 window.__NUWAX_CLIENT_TYPE__ 后,每次支付 API 请求都必须带上同名 Header(用下方 payFetch)。仅改页面内变量、Header 未带 → 前端走 /app/* 后端判非 App(9384),或前端走 /h5/* 后端判 App(9383)。
Detection + fetch wrapper (copy into pay.ts):
export const PAY_HEADER_CLIENT_TYPE = 'X-Client-Type';
function isAppFromClientTypeValue(clientType: string): boolean {
const n = clientType.trim().toLowerCase();
if (['web', 'h5', 'browser', 'wap'].includes(n)) return false;
if (n.startsWith('app') || ['native', 'ios', 'android', 'mobile', 'mobile-app', 'nuwax-app'].includes(n)) return true;
return true;
}
function isAppFromUserAgent(): boolean {
const ua = navigator.userAgent || '';
if (/NuwaxApp|NUWAX_APP|nuwax-app/i.test(ua)) return true;
if (/nuwax/i.test(ua) && (/webview|;\s*wv\)/i.test(ua))) return true;
return false;
}
export function resolveClientTypeHeader(): string | undefined {
const fromBridge = (window as any).__NUWAX_CLIENT_TYPE__ as string | undefined;
if (fromBridge?.trim()) return fromBridge.trim();
if (isAppFromUserAgent()) return 'app';
return undefined;
}
export function isAppWebView(): boolean {
const ct = resolveClientTypeHeader();
if (ct) return isAppFromClientTypeValue(ct);
return isAppFromUserAgent();
}
export function payFetch(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const clientType = resolveClientTypeHeader();
if (clientType) headers.set(PAY_HEADER_CLIENT_TYPE, clientType);
return fetch(input, { ...init, headers });
}
export function resolvePayApiSet(): 'app' | 'h5' | 'cashier' {
if (isAppWebView()) return 'app';
return 'h5';
}
| Client | APIs | After pay |
|---|
| App WebView | POST /api/pay/general/app/create-order → POST /api/pay/general/app/pay | WxPay(当前渠道): redirectUrl(weixin://...)→ wx.miniapp.launchMiniProgram;AliPay: 打开 redirectUrl;poll /status |
| Mobile/system browser (custom UI) | POST /api/pay/general/h5/create-order → POST /api/pay/general/h5/pay | Same two-path rule as pitfall #6 (iframe popup vs standalone return) |
| Any (default) | POST /api/pay/general/cashier | Cashier two-path rule (pitfall #6) |
App native invoke (WebView only — align with App 原生支付.md):
⚠️ 当前微信 App 渠道(安心付)不走 wxPayParams。 网关返回 invokeType=REDIRECT_URL + redirectUrl(weixin://dl/business/...),前端须解析 query= 后的 Base64,用 wx.miniapp.launchMiniProgram 拉起小程序(userName=gh_cd6acad9a40d,path=ipay/main?{param})。不要对微信 redirectUrl 使用 uni.requestPayment / 直接打开链接。
function parseAnxinfuMiniProgramParam(redirectUrl) {
if (!redirectUrl) return '';
const q = redirectUrl.split('query=')[1]?.split('&')[0] ?? '';
return q;
}
function launchAnxinfuWxPay(redirectUrl) {
const param = parseAnxinfuMiniProgramParam(redirectUrl);
if (!param) return Promise.reject(new Error('无法从 redirectUrl 解析支付参数'));
return new Promise((resolve, reject) => {
wx.miniapp.launchMiniProgram({
userName: 'gh_cd6acad9a40d',
path: 'ipay/main?' + param,
miniprogramType: 0,
success: (res) => {
const status = res?.data?.status;
if (status === 'success') resolve(res);
else if (status === 'cancel') reject(Object.assign(new Error('用户取消支付'), { status }));
else resolve(res);
},
fail: reject,
});
});
}
async function invokeAppPay(pay) {
if (pay.payChannel === 'WxPay') {
if (pay.wxPayParams) {
await invokeWxPrepay(pay.wxPayParams);
return;
}
if (pay.redirectUrl?.startsWith('weixin://')) {
await launchAnxinfuWxPay(pay.redirectUrl);
return;
}
throw new Error('微信 App 支付缺少 redirectUrl');
}
if (pay.payChannel === 'AliPay') {
if (pay.invokeType === 'REDIRECT_URL' && pay.redirectUrl) {
await openPayUrl(pay.redirectUrl);
return;
}
if (pay.invokeType === 'QRCODE_FALLBACK' && pay.qrCodeContent) {
showQrCode(pay.qrCodeContent);
return;
}
}
throw new Error('无法识别的支付调起参数');
}
async function payWithAppNative({ amountInFen, subject, payChannel, onPolling, onPaid, onFailed }) {
if (!isAppWebView()) throw new Error('App native pay requires App WebView');
const { orderNo, gatewayOrderNo } = await createAppOrder({ amountInFen, subject });
stashPendingOrder({ gatewayOrderNo, amountInFen, subject });
const res = await payFetch('/api/pay/general/app/pay', {
method: 'POST',
body: JSON.stringify({ orderNo, payChannel }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message);
const pay = { ...json.data, payChannel: json.data.payChannel ?? payChannel };
await invokeAppPay(pay);
onPolling?.();
return pollUntilPaid(gatewayOrderNo, {
onPaid: async (info) => { await onPaid?.(info); clearPendingOrder(gatewayOrderNo); },
onFailed,
});
}
launchMiniProgram 的 success 仅 UX — 业务是否支付成功必须轮询 POST /api/pay/general/status 直到 status === 'PAID'。
Bill 订单(需登录): App → POST /api/bill/order/pay/app-native + GET /api/bill/order/settlement-status;手机浏览器 → POST /api/bill/order/pay/h5-web。调起逻辑同上 invokeAppPay。
Important: Sandbox vs Production paths
There are two different contexts where these APIs are called, and the paths differ:
1. Agent development (debugging from the skill CLI / curl)
When you (the agent) test the API during development, use the sandbox path + sandbox key:
POST $PLATFORM_BASE_URL/api/v1/4sandbox/pay/general/cashier
Authorization: Bearer $SANDBOX_ACCESS_KEY
Environment variables PLATFORM_BASE_URL and SANDBOX_ACCESS_KEY are pre-set in the sandbox.
2. Project runtime (frontend code in the published project)
When the published project's frontend code calls the API at runtime, it MUST use the same-origin path with no Authorization header — the browser sends the login cookie/session automatically:
POST /api/pay/general/cashier
Never put a Bearer token in frontend code. The login session identifies the tenant.
Quick decision: Are you writing code that runs in the agent shell (curl/python)? → sandbox path + Bearer key. Are you writing frontend JS that runs in the user's browser? → same-origin /api/..., no key, projectId injected at build time (see pitfall #1).
API Reference
All endpoints are POST with Content-Type: application/json. Every response is wrapped in:
{ "code": "0000", "message": "success", "data": { ... } }
code === "0000" means success. On failure, message contains the error.
1. Cashier mode — POST /api/pay/general/cashier
Creates a scan order and a cashier session, returns the cashier URL.
Request body
| Field | Type | Required | Description |
|---|
projectId | string | yes | Project ID — from DEV_PROJECT_ID, injected at build time (see pitfall #1). Namespaces order numbers to prevent collisions between projects. |
bizOrderNo | string | no | Your own order number. If provided, the final order number is GP1_{projectId}_{bizOrderNo} (idempotent — safe for retries). If omitted, the system auto-generates GP2_{projectId}_{timestamp}_{random}. |
orderAmount | number | yes | Order amount in fen (分). 100 = 1.00 RMB. |
subject | string | yes | Order title / product description (shown on the cashier). |
frontNotifyUrl | string | no | Page URL to redirect the browser back to after payment. No # hash — see pitfall #2. ⚠️ The cashier returns this URL AS-IS and appends NO payment params — so do not rely on the return URL carrying gatewayOrderNo; recover the order handle from storage (pitfall #3). Can be empty. |
Response data
| Field | Type | Description |
|---|
orderNo | string | Business order number (GP1_ or GP2_ prefix). |
gatewayOrderNo | string | Gateway payment order number — keep this, you need it for /status and as the idempotency key. |
cashierUrl | string | Cashier URL — iframe dev: window.open(cashierUrl); standalone/prod: window.location.href = cashierUrl (pitfall #6). |
2. H5 create order — POST /api/pay/general/h5/create-order
Step 1 of H5 mode: creates the gateway order (does not call the channel yet). Mobile/system browser only — App WebView receives 9383.
Request body
| Field | Type | Required | Description |
|---|
projectId | string | yes | Project ID — from DEV_PROJECT_ID, injected at build time (see pitfall #1). |
bizOrderNo | string | no | Your own order number. If provided → GP1_{projectId}_{bizOrderNo}; if omitted → auto-generated GP2_{projectId}_{timestamp}_{random}. |
orderAmount | number | yes | Order amount in fen (分). |
subject | string | yes | Order title. |
Response data
| Field | Type | Description |
|---|
orderNo | string | Business order number (GP1_ or GP2_ prefix). |
gatewayOrderNo | string | Gateway payment order number. |
3. H5 pay — POST /api/pay/general/h5/pay
Step 2 of H5 mode: invokes the payment channel on an existing H5 order. The client IP is taken from the request automatically. Mobile/system browser only — App WebView receives 9383.
Request body
| Field | Type | Required | Description |
|---|
orderNo | string | yes | The orderNo returned by /h5/create-order. |
payChannel | string | yes | "WxPay" (微信支付) or "AliPay" (支付宝). Case-insensitive. |
frontNotifyUrl | string | yes | Page URL to return to after payment. Must be a valid http(s)://... URL with a host. No # hash — see pitfall #2. The backend wraps this into a channel callback relay (/api/pay/general/h5/front-notify) before calling WeChat/Alipay — you still pass your final target page here; do not call the relay URL yourself. |
Response data
| Field | Type | Description |
|---|
orderNo | string | Business order number. |
gatewayOrderNo | string | Gateway payment order number. |
formHtml | string | Auto-submit form HTML — when invokeType === "FORM_HTML", write it into the page (document.body.insertAdjacentHTML('beforeend', formHtml)). |
redirectUrl | string | Jump URL — when invokeType === "REDIRECT_URL", redirect the browser. |
invokeType | string | "FORM_HTML" / "REDIRECT_URL" / "QRCODE_FALLBACK". |
status | string | Order status right after the call: "INIT" / "PENDING" / "PAID" / "FAILED" / "CLOSED". |
H5 channel return relay (internal — do NOT call from frontend)
When /h5/pay is called, the backend replaces your frontNotifyUrl with a channel callback URL of the form:
{origin}/api/pay/general/h5/front-notify?returnUrl={urlencoded frontNotifyUrl}
After the user pays, WeChat/Alipay hit that relay (GET or POST, no login required). The relay validates returnUrl and responds with 302 to your original frontNotifyUrl. You never construct or fetch this URL yourself — just pass the final result-page URL as frontNotifyUrl in /h5/pay.
⚠️ App WebView: do not call /h5/create-order or /h5/pay — use App native endpoints below (pitfall #7).
4. App create order — POST /api/pay/general/app/create-order
Step 1 of App native mode (App WebView only). Creates the gateway order without calling the channel.
Request body
| Field | Type | Required | Description |
|---|
projectId | string | yes | Project ID — from DEV_PROJECT_ID, injected at build time. |
bizOrderNo | string | no | Same semantics as H5 — GP1_ / GP2_ prefixes. |
orderAmount | number | yes | Amount in fen (分). |
subject | string | yes | Order title. |
Response data
| Field | Type | Description |
|---|
orderNo | string | Business order number. |
gatewayOrderNo | string | Gateway payment order number — use for /status and stash. |
Errors: non-App clients receive 9384 (pay_app_native_requires_app).
5. App pay — POST /api/pay/general/app/pay
Step 2 of App native mode: invokes channel and returns invoke params. No frontNotifyUrl — poll /status after SDK / mini-program callback.
Request body
| Field | Type | Required | Description |
|---|
orderNo | string | yes | orderNo from /app/create-order. |
payChannel | string | yes | "WxPay" or "AliPay". |
Response data
| Field | Type | Description |
|---|
orderNo | string | Business order number. |
gatewayOrderNo | string | Gateway payment order number. |
payChannel | string | "WxPay" / "AliPay". |
invokeType | string | "REDIRECT_URL" (current WxPay & most AliPay) or "QRCODE_FALLBACK" (AliPay fallback). |
redirectUrl | string | WxPay(当前渠道): weixin://dl/business/?appid=...&path=ipay/main&query={Base64}&env_version=release → 解析 query 后 wx.miniapp.launchMiniProgram,不要直接打开或 uni.requestPayment。AliPay: https://qr.alipay.com/... 或 alipays://... → 打开链接。 |
wxPayParams | object | 其他微信渠道 prepay JSON(当前安心付渠道为 null)。有值时用 invokeWxPrepay / PayReq 兼容。 |
qrCodeContent | string | AliPay invokeType=QRCODE_FALLBACK 时的二维码内容。 |
alipayTradeNo | string | 部分支付宝 SDK 使用(当前多为 null)。 |
status | string | 一般为 "PENDING"。 |
WxPay 示例(当前实际返回):
{
"code": "0000",
"data": {
"orderNo": "GP1_123_xxx",
"gatewayOrderNo": "12026063020260474001",
"payChannel": "WxPay",
"invokeType": "REDIRECT_URL",
"redirectUrl": "weixin://dl/business/?appid=wx98a5c8f239de55f8&path=ipay/main&query=OTk5OTQjMTIwMjYwNjMwMjExMDQ1NzgwMDE=&env_version=release",
"wxPayParams": null,
"status": "PENDING"
}
}
AliPay 示例:
{
"code": "0000",
"data": {
"payChannel": "AliPay",
"invokeType": "REDIRECT_URL",
"redirectUrl": "https://qr.alipay.com/bax03117xnkonkdhllom55ed"
}
}
前端调起: 使用 pitfall #7 的 invokeAppPay(pay) — 按 payChannel + invokeType 分支,禁止把 redirectUrl 仅当作支付宝字段。
Errors: non-App clients receive 9384. App WebView calling /h5/* receives 9383.
6. Status query — POST /api/pay/general/status
Queries the payment status and synchronizes with the channel. Safe to poll. This is the only reliable way to confirm payment success — poll it on the return page.
Request body
| Field | Type | Required | Description |
|---|
gatewayOrderNo | string | yes | Gateway order number from /cashier, /h5/create-order, or /app/create-order. |
Response data
| Field | Type | Description |
|---|
status | string | "INIT" / "PENDING" / "PAID" / "FAILED" / "CLOSED". |
payChannel | string | "WxPay" / "AliPay" / "UnionPay" (null if not yet paid). |
payMode | string | "scan" / "h5" / "app" / "minipay". |
orderAmount | number | Amount in fen. |
paidAt | string | ISO timestamp when paid (null if not paid). |
Treat the order as paid when status === "PAID". Do not rely on paidAt alone.
Agent workflow
⚠️ projectId bootstrap is Step 0 — it MUST be completed and verified before writing any payment code. If Step 0 is not done, every subsequent step will fail at runtime with "projectId must not be blank". See pitfall #1 for the full explanation.
Standard integration (cashier mode — recommended starting point)
Step 0 — Bootstrap projectId (MANDATORY, do this FIRST):
echo "DEV_PROJECT_ID=$DEV_PROJECT_ID" > .env
cat .env
pnpm build && grep -c "$DEV_PROJECT_ID" dist/assets/*.js
If Step 0d returns 0, stop. The integration is broken. Go back to pitfall #1 and fix the .env file / vite.config.ts before writing any payment UI code. Proceeding with an empty projectId guarantees "projectId must not be blank" errors at runtime.
- Ensure
projectId is injected at build time and verified (Step 0 above), and vite.config.ts has base: './' (pitfall #4).
- Decide the amount (in fen) and a short
subject for the order.
- Frontend calls
POST /api/pay/general/cashier with { projectId, orderAmount, subject, frontNotifyUrl } — frontNotifyUrl has no hash, just a ?from=pay-result flag (pitfall #2). In iframe dev preview this URL is only for the popup cashier return; the iframe page does not navigate there.
stashPendingOrder(...) before opening cashier (both paths — pitfall #3).
- Open cashier — two paths (pitfall #6, MANDATORY):
- iframe dev preview:
openCashier(cashierUrl) → window.open — iframe never navigates away
- standalone / prod:
window.location.href = cashierUrl
- Wait for result — two paths:
- iframe dev preview: poll
/status on the pay page; on PAID show success in place, clearPendingOrder, idempotent business write (pitfall #5).
- standalone / prod: cashier redirects to
frontNotifyUrl → pre-router guard → result page → poll → business write (steps 7–9 below).
Standalone / prod return-redirect path only (steps 7–9):
- Pre-router guard (module scope, before router — pitfall #2) rewrites
?from=pay-result to #/pay-result.
- Result page: recover
gatewayOrderNo from storage (pitfall #3) → poll /status.
- On
PAID: idempotent business write (pitfall #5) + cleanup stash.
Step 10 — Self-check (HARD GATE):
Path A — iframe dev preview (MANDATORY if testing in platform preview):
- Click Pay → a new popup window opens the cashier (iframe URL unchanged).
- Pay page shows "等待支付…" / polling state; Network tab shows repeated
POST /api/pay/general/status.
- After real or mocked PAID, success UI appears on the pay page inside iframe without iframe navigation.
pendingOrder:* keys cleared after PAID.
Path B — standalone return flow (MANDATORY before prod publish):
Shape B — standalone return URL simulation (flag only, no order number) — MANDATORY before prod:
http://localhost:5173/?from=pay-result
PRECONDITION: stash in storage first:
localStorage.setItem('pendingOrder:TEST_GW_123',
JSON.stringify({gatewayOrderNo:'TEST_GW_123',amount:100,name:'test'}))
For Path B you MUST observe (all three):
- Address bar ends in
#/pay-result; result page renders polling state; /status polls fire.
Pass = Path A (iframe popup + in-place result) AND Path B (standalone return URL). Do not ship if iframe dev uses location.href to cashier.
Custom UI integration (H5 mode — mobile/system browser only)
Prerequisite: Step 0. Only for non-App clients — if isAppWebView() use App native flow (pitfall #7). iframe dev preview uses the same two-path rule (pitfall #6): poll on stay-page for iframe; return-redirect for standalone.
- Frontend calls
POST /api/pay/general/h5/create-order — keep orderNo and gatewayOrderNo.
- Show channel picker.
stashPendingOrder() before invoking channel.
- iframe dev preview: open channel in new window when possible (
window.open(redirectUrl) for REDIRECT_URL; for FORM_HTML write form into a popup via window.open('about:blank') then document.write(formHtml)). Poll /status on pay page; show result in place; clearPendingOrder on PAID.
- standalone / prod: invoke channel in same window (
formHtml inject or location.href = redirectUrl); after relay returns to frontNotifyUrl, use return page + guard + poll (pitfall #2–#5).
- Pass
frontNotifyUrl to /h5/pay in both paths (relay URL for channel; iframe page ignores the return).
Step 10: Path A (iframe popup + poll) + Path B (standalone H5 return) both required.
App native integration (App WebView only)
Prerequisite: Step 0. Only when isAppWebView() is true. Never call H5 endpoints from App (9383). Full guide: App 原生支付.md in pay-web module.
- Before pay, confirm
isAppWebView() — if false, use H5 or cashier instead.
POST /api/pay/general/app/create-order — keep orderNo and gatewayOrderNo.
stashPendingOrder() before invoking pay.
POST /api/pay/general/app/pay with { orderNo, payChannel }.
invokeAppPay(data) (pitfall #7):
- WxPay +
redirectUrl starts with weixin://: parseAnxinfuMiniProgramParam → wx.miniapp.launchMiniProgram(userName=gh_cd6acad9a40d, path=ipay/main?{param})
- WxPay +
wxPayParams: invokeWxPrepay(其他渠道兼容)
- AliPay +
REDIRECT_URL: openPayUrl(redirectUrl)(如 https://qr.alipay.com/...)
- AliPay +
QRCODE_FALLBACK: showQrCode(qrCodeContent)
- SDK / 小程序回调后 poll
POST /api/pay/general/status until PAID — 不要仅依赖 launchMiniProgram 的 success。
- On PAID: idempotent business write +
clearPendingOrder.
Frontend integration templates
Amount conversion
The API takes amount in fen (分). Convert RMB to fen with Math.round(rmb * 100) — never use rmb * 100 directly (floating-point: 0.1 + 0.2).
Build-time projectId + status helpers (shared by all templates)
All templates below assume you have injected projectId at build time as a global constant (pitfall #1). They read PROJECT_ID and poll status from a small helper module:
declare const __APP_PROJECT_ID__: string;
export const PROJECT_ID = typeof __APP_PROJECT_ID__ !== 'undefined' ? __APP_PROJECT_ID__ : '';
const PENDING_LATEST_KEY = 'pendingOrder:latest';
export const PAY_HEADER_CLIENT_TYPE = 'X-Client-Type';
function isAppFromClientTypeValue(clientType: string): boolean {
const n = clientType.trim().toLowerCase();
if (['web', 'h5', 'browser', 'wap'].includes(n)) return false;
if (n.startsWith('app') || ['native', 'ios', 'android', 'mobile', 'mobile-app', 'nuwax-app'].includes(n)) return true;
return true;
}
function isAppFromUserAgent(): boolean {
const ua = navigator.userAgent || '';
if (/NuwaxApp|NUWAX_APP|nuwax-app/i.test(ua)) return true;
if (/nuwax/i.test(ua) && (/webview|;\s*wv\)/i.test(ua))) return true;
return false;
}
export function resolveClientTypeHeader(): string | undefined {
const fromBridge = (window as any).__NUWAX_CLIENT_TYPE__ as string | undefined;
if (fromBridge?.trim()) return fromBridge.trim();
if (isAppFromUserAgent()) return 'app';
return undefined;
}
export function isAppWebView(): boolean {
const ct = resolveClientTypeHeader();
if (ct) return isAppFromClientTypeValue(ct);
return isAppFromUserAgent();
}
export function payFetch(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const clientType = resolveClientTypeHeader();
if (clientType) headers.set(PAY_HEADER_CLIENT_TYPE, clientType);
return fetch(input, { ...init, headers });
}
export function isEmbeddedPreview() {
try { return window.self !== window.top; } catch { return true; }
}
export function buildFrontNotifyUrl() {
return window.location.origin + window.location.pathname + '?from=pay-result';
}
export function stashPendingOrder(pending: Record<string, unknown>) {
const json = JSON.stringify(pending);
const gw = String(pending.gatewayOrderNo ?? '');
localStorage.setItem(`pendingOrder:${gw}`, json);
localStorage.setItem(PENDING_LATEST_KEY, json);
try { sessionStorage.setItem('pendingOrder', json); } catch { }
}
export function clearPendingOrder(gatewayOrderNo: string) {
try { sessionStorage.removeItem('pendingOrder'); } catch { }
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`);
localStorage.removeItem(PENDING_LATEST_KEY);
}
export function openCashier(cashierUrl: string) {
if (isEmbeddedPreview()) {
const w = window.open(cashierUrl, '_blank', 'noopener,noreferrer');
if (!w) throw new Error('弹窗被拦截,请允许弹出窗口后重试');
return w;
}
window.location.href = cashierUrl;
return null;
}
export function recoverPendingFromLocalStorage() {
try {
const latest = JSON.parse(localStorage.getItem(PENDING_LATEST_KEY) || 'null');
if (latest?.gatewayOrderNo) return latest;
} catch { }
const entries: Record<string, unknown>[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith('pendingOrder:') || key === PENDING_LATEST_KEY) continue;
try {
const o = JSON.parse(localStorage.getItem(key) || 'null');
if (o?.gatewayOrderNo) entries.push(o);
} catch { }
}
return entries.length ? entries[entries.length - 1] : null;
}
export function resolvePendingOrder() {
let stashed: Record<string, unknown> | null = null;
try { stashed = JSON.parse(sessionStorage.getItem('pendingOrder') || 'null'); } catch { }
if (!stashed?.gatewayOrderNo) stashed = recoverPendingFromLocalStorage();
if (!stashed || typeof stashed !== 'object' || !stashed.gatewayOrderNo) return null;
return stashed;
}
const API = {
cashier: '/api/pay/general/cashier',
h5CreateOrder: '/api/pay/general/h5/create-order',
h5Pay: '/api/pay/general/h5/pay',
appCreateOrder: '/api/pay/general/app/create-order',
appPay: '/api/pay/general/app/pay',
status: '/api/pay/general/status',
};
export async function createH5Order({ amountInFen, subject, bizOrderNo }: { amountInFen: number; subject: string; bizOrderNo?: string }) {
if (isAppWebView()) throw new Error('H5 pay not allowed in App — use app/create-order');
const res = await payFetch(API.h5CreateOrder, {
method: 'POST',
body: JSON.stringify({ projectId: PROJECT_ID, orderAmount: amountInFen, subject, bizOrderNo }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message || 'create H5 order failed');
return json.data as { orderNo: string; gatewayOrderNo: string };
}
export async function createAppOrder({ amountInFen, subject, bizOrderNo }: { amountInFen: number; subject: string; bizOrderNo?: string }) {
if (!isAppWebView()) throw new Error('App native pay requires App WebView');
const res = await payFetch(API.appCreateOrder, {
method: 'POST',
body: JSON.stringify({ projectId: PROJECT_ID, orderAmount: amountInFen, subject, bizOrderNo }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message || 'create app order failed');
return json.data as { orderNo: string; gatewayOrderNo: string };
}
export async function createCashierOrder({ amountInFen, subject, frontNotifyUrl }) {
const res = await payFetch(API.cashier, {
method: 'POST',
body: JSON.stringify({ projectId: PROJECT_ID, orderAmount: amountInFen, subject, frontNotifyUrl }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message || 'create order failed');
return json.data;
}
export async function queryStatus(gatewayOrderNo) {
const res = await payFetch(API.status, {
method: 'POST',
body: JSON.stringify({ gatewayOrderNo }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message || 'status query failed');
return json.data;
}
export function pollStatus(gatewayOrderNo, { onPaid, onFailed, onUpdate } = {}, intervalMs = 2500, timeoutMs = 60 * 60 * 1000) {
let stop = false;
const start = Date.now();
(async () => {
while (!stop && Date.now() - start < timeoutMs) {
try {
const info = await queryStatus(gatewayOrderNo);
onUpdate?.(info);
if (info.status === 'PAID') { onPaid?.(info); return; }
if (info.status === 'FAILED' || info.status === 'CLOSED') { onFailed?.(info); return; }
} catch {
}
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!stop) onFailed?.({ status: 'CLOSED' });
})();
return () => { stop = true; };
}
export function pollUntilPaid(
gatewayOrderNo: string,
{ onPaid, onFailed, onUpdate }: { onPaid?: (info: unknown) => void; onFailed?: (info: unknown) => void; onUpdate?: (info: unknown) => void } = {},
intervalMs = 2500,
timeoutMs = 60 * 60 * 1000,
) {
return pollStatus(gatewayOrderNo, {
onPaid: (info) => { onPaid?.(info); },
onFailed,
onUpdate,
}, intervalMs, timeoutMs);
}
Cashier mode — unified pay entry (framework-agnostic)
async function payWithCashier({ amountInFen, subject, name, message, onPolling, onPaid, onFailed }) {
const frontNotifyUrl = buildFrontNotifyUrl();
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({ amountInFen, subject, frontNotifyUrl });
const pending = { gatewayOrderNo, amountInFen, subject, name, message };
stashPendingOrder(pending);
if (isEmbeddedPreview()) {
openCashier(cashierUrl);
onPolling?.();
return pollUntilPaid(gatewayOrderNo, {
onPaid: async (info) => {
await onPaid?.(info, pending);
clearPendingOrder(gatewayOrderNo);
},
onFailed,
});
}
window.location.href = cashierUrl;
}
Cashier mode — standalone return page poll (prod only)
React (cashier mode + hash return guard + idempotent result page)
import { createHashRouter, RouterProvider } from 'react-router-dom';
import { ROUTES } from './router';
const PAY_FLAG = 'from';
const PAY_FLAG_VALUE = 'pay-result';
const PAY_PARAMS = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt'];
function isPayReturn(sp) {
if (sp.get(PAY_FLAG) === PAY_FLAG_VALUE) return true;
return PAY_PARAMS.some((k) => !!sp.get(k));
}
(function normalizePayReturnUrl() {
const url = new URL(window.location.href);
const hashSp = new URLSearchParams((url.hash.split('?')[1]) || '');
const source = isPayReturn(url.searchParams) ? url.searchParams
: isPayReturn(hashSp) ? hashSp : null;
if (!source) return;
const pairs = [];
for (const key of PAY_PARAMS) {
const v = source.get(key);
if (v) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
}
source.forEach((v, k) => {
if (k === PAY_FLAG || PAY_PARAMS.includes(k)) return;
pairs.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
});
url.search = '';
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`;
window.history.replaceState(null, '', url.toString());
})();
const router = createHashRouter(ROUTES);
export default function App() {
return <RouterProvider router={router} />;
}
import { useState, useRef } from 'react';
import { payWithCashier } from './pay-with-cashier';
import { createBusinessRecord } from './pay';
export default function PayButton({ amountInFen, subject, name, message }) {
const [loading, setLoading] = useState(false);
const [phase, setPhase] = useState('idle');
const stopPollRef = useRef(null);
const handlePay = async () => {
setLoading(true);
setPhase('idle');
try {
stopPollRef.current = await payWithCashier({
amountInFen, subject, name, message,
onPolling: () => { setPhase('polling'); setLoading(false); },
onPaid: async (info, pending) => {
const gw = pending.gatewayOrderNo;
const KEY = `paid:${gw}`;
if (localStorage.getItem(KEY) !== '1') {
localStorage.setItem(KEY, '1');
await createBusinessRecord({
gatewayOrderNo: gw,
amount: pending.amount ?? info.orderAmount ?? 0,
name: pending.name ?? 'anonymous',
message: pending.message ?? '',
});
}
setPhase('paid');
},
onFailed: () => setPhase('failed'),
});
} catch (e) {
setPhase('failed');
setLoading(false);
}
};
return (
<div className="pay-page">
<button disabled={loading || phase === 'polling'} onClick={handlePay}>
{loading ? '处理中…' : phase === 'polling' ? '等待支付完成…' : `支付 ¥${(amountInFen / 100).toFixed(2)}`}
</button>
{phase === 'paid' && <p>支付成功</p>}
{phase === 'failed' && <p style={{ color: 'red' }}>支付未完成或已取消</p>}
</div>
);
}
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { pollStatus, resolvePendingOrder, createBusinessRecord } from './pay';
function resolveGatewayOrderNoFromUrl(searchParams) {
const hq = new URLSearchParams((window.location.hash.split('?')[1]) || '');
const q = new URLSearchParams(window.location.search);
return hq.get('gatewayOrderNo') || q.get('gatewayOrderNo')
|| hq.get('orderNo') || q.get('orderNo')
|| searchParams.get('gatewayOrderNo') || searchParams.get('orderNo') || '';
}
export default function PayResult() {
const [state, setState] = useState('pending');
const [searchParams] = useSearchParams();
useEffect(() => {
const stashed = resolvePendingOrder();
const gatewayOrderNo =
stashed?.gatewayOrderNo || resolveGatewayOrderNoFromUrl(searchParams);
if (!gatewayOrderNo) { setState('failed'); return; }
const KEY = `paid:${gatewayOrderNo}`;
const stop = pollStatus(
gatewayOrderNo,
{
onPaid: async (info) => {
if (localStorage.getItem(KEY) !== '1') {
localStorage.setItem(KEY, '1');
try {
await createBusinessRecord({
gatewayOrderNo,
amount: stashed?.amount ?? info.orderAmount ?? 0,
name: stashed?.name ?? 'anonymous',
message: stashed?.message ?? '',
});
sessionStorage.removeItem('pendingOrder');
localStorage.removeItem(`pendingOrder:${gatewayOrderNo}`);
localStorage.removeItem('pendingOrder:latest');
} catch (e) {
localStorage.removeItem(KEY);
console.error(e);
setState('paid');
return;
}
}
setState('paid');
},
onFailed: () => setState('failed'),
},
2500,
60 * 60 * 1000
);
return () => stop();
}, []);
if (state === 'paid') return <div className="pay-result-page"><h1>支付成功</h1></div>;
if (state === 'failed') return <div className="pay-result-page"><h1>支付未完成或已取消</h1></div>;
return <div className="pay-result-page"><h1>支付结果确认中…</h1></div>;
}
Vue 3 (cashier mode + hash return guard)
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
;(function normalizePayReturnUrl() {
const url = new URL(window.location.href)
if (url.searchParams.get('from') !== 'pay-result') return
const keep = ['orderNo', 'gatewayOrderNo', 'status', 'payChannel', 'paidAt']
const pairs: string[] = []
url.searchParams.forEach((v, k) => {
if (k === 'from' || !keep.includes(k)) return
pairs.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
})
url.search = ''
url.hash = `#/pay-result${pairs.length ? `?${pairs.join('&')}` : ''}`
window.history.replaceState(null, '', url.toString())
})()
const router = createRouter({ history: createWebHashHistory(), routes: [] })
createApp(App).use(router).mount('#app')
The Vue App.vue itself needs no return-guard logic — keep it a plain <RouterView />. The guard lives in main.ts (or wherever the router is constructed), before createRouter.
<!-- PayButton.vue -->
<script setup>
import { ref } from 'vue'
import { createCashierOrder, buildFrontNotifyUrl, stashPendingOrder, openCashier, isEmbeddedPreview, pollUntilPaid, clearPendingOrder } from './pay'
const props = defineProps({ amountInFen: Number, subject: String })
const loading = ref(false)
const phase = ref('idle')
let stopPoll = null
async function pay() {
loading.value = true
phase.value = 'idle'
try {
const frontNotifyUrl = buildFrontNotifyUrl()
const { gatewayOrderNo, cashierUrl } = await createCashierOrder({
amountInFen: props.amountInFen,
subject: props.subject,
frontNotifyUrl,
})
stashPendingOrder({ gatewayOrderNo, amountInFen: props.amountInFen, subject: props.subject })
if (isEmbeddedPreview()) {
openCashier(cashierUrl)
phase.value = 'polling'
loading.value = false
stopPoll = pollUntilPaid(gatewayOrderNo, {
onPaid: () => { clearPendingOrder(gatewayOrderNo); phase.value = 'paid' },
onFailed: () => { phase.value = 'failed' },
})
} else {
window.location.href = cashierUrl
}
} catch (e) {
phase.value = 'failed'
loading.value = false
}
}
</script>
```vue
<template>
<div class="pay-page">
<button :disabled="loading || phase === 'polling'" @click="pay">
{{ loading ? '处理中…' : phase === 'polling' ? '等待支付完成…' : `支付 ¥${(amountInFen / 100).toFixed(2)}` }}
</button>
<p v-if="phase === 'paid'">支付成功</p>
<p v-if="phase === 'failed'" style="color: red">支付未完成或已取消</p>
</div>
</template>
Vue PayResult is for standalone return URL only. iframe dev shows result on the pay button page.
H5 mode — create order, stash, invoke channel
function openH5Channel({ invokeType, formHtml, redirectUrl }) {
if (isEmbeddedPreview()) {
if (invokeType === 'REDIRECT_URL' && redirectUrl) {
const w = window.open(redirectUrl, '_blank', 'noopener,noreferrer');
if (!w) throw new Error('弹窗被拦截,请允许弹出窗口后重试');
return;
}
if (invokeType === 'FORM_HTML' && formHtml) {
const w = window.open('about:blank', '_blank', 'noopener,noreferrer');
if (!w) throw new Error('弹窗被拦截,请允许弹出窗口后重试');
w.document.write(formHtml);
w.document.close();
return;
}
if (redirectUrl) window.open(redirectUrl, '_blank', 'noopener,noreferrer');
return;
}
if (invokeType === 'FORM_HTML' && formHtml) {
document.body.insertAdjacentHTML('beforeend', formHtml);
} else if (redirectUrl) {
window.location.href = redirectUrl;
}
}
async function payWithH5({ amountInFen, subject, payChannel, name, message, onPolling, onPaid, onFailed }) {
if (isAppWebView()) throw new Error('H5 pay not allowed in App — use payWithAppNative');
const { orderNo, gatewayOrderNo } = await createH5Order({ amountInFen, subject });
const frontNotifyUrl = buildFrontNotifyUrl();
const pending = { gatewayOrderNo, amountInFen, subject, name, message };
stashPendingOrder(pending);
const res = await payFetch('/api/pay/general/h5/pay', {
method: 'POST',
body: JSON.stringify({ orderNo, payChannel, frontNotifyUrl }),
});
const json = await res.json();
if (json.code !== '0000') throw new Error(json.message);
const { invokeType, formHtml, redirectUrl } = json.data;
if (isEmbeddedPreview()) {
openH5Channel({ invokeType, formHtml, redirectUrl });
onPolling?.();
return pollUntilPaid(gatewayOrderNo, {
onPaid: async (info) => { await onPaid?.(info, pending); clearPendingOrder(gatewayOrderNo); },
onFailed,
});
}
openH5Channel({ invokeType, formHtml, redirectUrl });
}
iframe dev: poll on pay page, show result in place. standalone: channel returns via relay → frontNotifyUrl → result page flow.
App native mode — invokeAppPay + poll
Copy parseAnxinfuMiniProgramParam, launchAnxinfuWxPay, invokeAppPay from pitfall #7. WebView 壳无 wx.miniapp 时,原生需封装 launchMiniProgram JSBridge,参数与上文一致。
async function payWithAppNative({ amountInFen, subject, payChannel, name, message, onPolling, onPaid, onFailed }) {
if (!isAppWebView()) throw new Error('App native pay requires App WebView');
const { orderNo, gatewayOrderNo } = await createAppOrder({ amountInFen, subject });
const pending = { gatewayOrderNo, amountInFen, subject, name, message };
stashPendingOrder(pending);
const res = await payFetch('/api/pay/general/app/pay', {
method: 'POST',
body: JSON.stringify({ orderNo, payChannel }),
});
const json = await res.json();