WeChat QR code
Use wechat_native to show a QR code that the customer scans with WeChat on another device. This works well for desktop checkout and payment screens. For a customer paying on the same phone as your native app, use WeChat App.
1. Prepare the checkout screen
Have wechat_native enabled and a backend with payments:write and payments:read. Your screen needs a QR renderer, an expiry state, and a way to refresh the business order through your own backend. No native WeChat SDK is needed on the device displaying the code.
The returned value is payment content to encode, not a QR image. WeChat's Native flow uses a provider payment URI, which may begin with weixin://; see the official Native payment guide.
Setup checklist
- Activate
wechat_nativeand configure a Payment API key on your backend. The screen showing the code does not need the WeChat SDK; the customer scans using WeChat on a second device. - Build an authenticated checkout page with a canvas, amount, expiry indicator, status message, and Check payment button. Keep the page open while the customer scans.
- Implement your own checkout and order-status routes as shown in Connect your app to checkout. Copy its browser helper into your frontend source.
- Install the QR renderer in step 3. Generate QR images locally in your frontend or backend; the API returns a URI, not an image file.
- Persist the business-to-platform order mapping before displaying the code. Keep unfinished orders in your server-side history so leaving the page does not lose them.
Checkpoint: the display device can query an authenticated order, and the scanning device has WeChat installed. You do not need a WeChat AppID in the display page's JavaScript.
2. Create the order on your backend
Authenticate the customer, calculate the price, and save the business order and payment attempt as described in Build your payment backend. Save the following body as payment.json, replacing the application and external order IDs:
{
"app_id": "app_demo",
"payment_entry": "wechat_native",
"amount_minor": 1000,
"currency": "CNY",
"external_order_id": "shop_order_1042",
"description": "100 app credits"
}
curl --fail-with-body --request POST \
'https://pay.appconnect.cn/payment/v1/payment-sessions' \
--header "Authorization: Bearer $PAYMENT_API_KEY" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: create-shop-order-1042-v1' \
--data-binary @payment.json
Use the key and exact body persisted for this attempt. Save data.order.id before returning the business order ID and data.invocation to the client. The Payment API key stays on your server.
If the invocation is null, keep the same order and retrieve its payment session. Do not create another order while the first result is unresolved.
3. Display the QR code
Expect invocation.kind === "qr_url". Encode the exact invocation.qr_url string.
Install qrcode in your checkout frontend and pin it in your lockfile:
npm install qrcode
Add a canvas to your payment screen:
<canvas id="payment-qr" aria-label="Scan to pay" hidden></canvas>
The example uses the qrcode library's browser renderer. Bundle it with your application; do not send the payment URL to a public QR-generation service.
import QRCode from 'qrcode';
let qrExpiryTimer;
async function showWechatQr(invocation) {
const canvas = document.getElementById('payment-qr');
canvas.hidden = true;
clearTimeout(qrExpiryTimer);
const value = requireInvocation(invocation, 'qr_url');
const expires = Date.parse(invocation.expires_at);
await QRCode.toCanvas(canvas, value, {
errorCorrectionLevel: 'M', margin: 4, scale: 6,
});
if (Date.now() >= expires) throw new Error('Payment instructions expired while rendering');
canvas.hidden = false;
// Re-arm for long expiry intervals instead of overflowing setTimeout's limit.
function expireCode() {
const remaining = expires - Date.now();
if (remaining > 0) {
qrExpiryTimer = setTimeout(expireCode, Math.min(remaining, 2147483647));
} else {
canvas.hidden = true;
document.getElementById('payment-status').textContent = 'Payment link expired. Check payment.';
}
}
expireCode();
}
Call this with your backend's invocation. Keep the code sharp, uncropped, and large enough to scan. Show the amount, currency, and an instruction to scan with WeChat. Do not put qr_url in an image src or navigate the desktop browser to it.
Start polling your own order-status endpoint after rendering. Scanning does not necessarily produce a callback to the page displaying the QR code. Hide the code on confirmed payment, closure, or expiry; the backend continues reconciliation after expiry.
Wire the Pay button to this handler
Use this complete markup in place of the canvas-only example above; include each ID once. The backend renders the CSRF token using your normal session protection. The cart ID below is illustrative; use the cart owned by the signed-in customer.
<meta name="csrf-token" content="SERVER_RENDERED_TOKEN">
<button id="pay" type="button">Pay</button>
<button id="check-payment" type="button">Check payment</button>
<p id="payment-status" role="status"></p>
<canvas id="payment-qr" aria-label="Scan to pay" hidden></canvas>
Copy this into the same frontend module as the renderer above. Its requireInvocation import is also used by the renderer:
import { checkoutRequest, requireInvocation } from './checkout-browser.mjs';
const payButton = document.getElementById('pay');
const status = document.getElementById('payment-status');
payButton.addEventListener('click', async () => {
payButton.disabled = true;
try {
const checkout = await checkoutRequest('/api/checkout', {
method: 'POST',
csrfToken: document.querySelector('meta[name="csrf-token"]').content,
body: { cartId: 'cart_1042', paymentEntry: 'wechat_native' },
});
sessionStorage.setItem('pendingBusinessOrderId', checkout.businessOrderId);
if (checkout.paymentStatus === 'paid') {
window.location.assign('/orders/' + encodeURIComponent(checkout.businessOrderId));
return;
}
if (!['pending', 'processing'].includes(checkout.paymentStatus) || !checkout.invocation) {
status.textContent = 'Payment is not ready. Check the existing order.';
return;
}
await showWechatQr(checkout.invocation);
startStatusCheck();
} catch {
status.textContent = 'Unable to open payment. Your order can be checked before retrying.';
} finally { payButton.disabled = false; }
});
The /orders/… path is your application's order page. POST /api/checkout must reuse an unresolved attempt, so another tap cannot silently create a duplicate charge. Your backend supplies any entry-specific scene fields from trusted configuration.
Watch the order while the customer scans
Add this in the same frontend module. It queries your backend with bounded polling and updates the page even if the scanning device never returns to your website:
import { watchOrder } from './checkout-browser.mjs';
let watching = false;
const controller = new AbortController();
window.addEventListener('pageshow', event => { if (event.persisted) window.location.reload(); });
window.addEventListener('pagehide', () => { controller.abort(); clearTimeout(qrExpiryTimer); }, { once: true });
async function startStatusCheck() {
const id = sessionStorage.getItem('pendingBusinessOrderId');
if (!id || watching || controller.signal.aborted) return;
watching = true;
try {
const result = await watchOrder(id, order => {
status.textContent = order.paymentStatus === 'paid'
? (order.fulfillmentStatus === 'delivered' ? 'Purchase complete' : 'Payment received. Preparing your purchase.')
: `Payment status: ${order.paymentStatus}`;
if (['paid', 'failed', 'closed'].includes(order.paymentStatus)) {
document.getElementById('payment-qr').hidden = true;
clearTimeout(qrExpiryTimer);
}
}, { signal: controller.signal });
if (!result) status.textContent = 'Still confirming. Check again or open your order history.';
} catch {
if (!controller.signal.aborted) status.textContent = 'Cannot check payment yet. Your order is saved.';
} finally { watching = false; }
}
document.getElementById('check-payment').addEventListener('click', startStatusCheck);
if (sessionStorage.getItem('pendingBusinessOrderId')) startStatusCheck();
The renderer's timer hides the code at the actual invocation's expires_at and shows Payment link expired. Check payment. Keep status checks available. Your backend still reconciles a payment that began before expiry. Do not generate a fresh order from the timer.
4. Confirm payment and deliver the purchase
When the customer returns, ask your backend for the business order's status. Your backend checks ownership and retrieves the saved platform order:
curl --fail-with-body \
'https://pay.appconnect.cn/payment/v1/orders/ord_demo_001' \
--header "Authorization: Bearer $PAYMENT_API_KEY"
Validate the returned identity, currency, and amount. Fulfill only after the authoritative status is paid, using a unique fulfillment record. A callback or a return to your page is a reason to check this order, not a reason to grant the purchase.
Implement webhooks and order reconciliation so confirmation works even when the customer closes the app or browser. Show a pending state for unresolved results and offer Check payment instead of immediately starting another charge.
5. Refund or close the order
For a paid order, follow Refund a payment to submit a full or partial refund and track it to completion. Use the saved platform order ID; your client does not refund through the provider SDK.
For an abandoned unpaid order, use the close-order flow. Removing the payment UI does not close the order. Run the integration checklist before release.
6. Test the scanning journey
Display a fresh code, scan it with WeChat on another device, and verify that the original screen changes to the confirmed result. Repeat a cancelled scan, payment near expiry, a page reload, and a closed tab. A webhook/background query must recover payment without any browser callback from the scanning phone.
7. Common problems and how to fix them
| Symptom | Check and fix |
|---|---|
| Broken image or no QR code | qr_url is text to encode, not an image URL. Call the QR renderer with the complete URI; do not put it in an image src. |
Browser rejects weixin:// | You are trying to navigate instead of encode. Use the URI as QR content and let the customer's WeChat scanner handle it. |
| Code is blurry or cannot be scanned | Preserve the renderer's quiet margin, integer-sized modules, strong contrast, and natural resolution. Remove overlays and avoid CSS scaling that blurs modules. Test the displayed screen, not only the generated file. |
| Customer is using the same phone as the QR screen | Offer the native App flow in a native app, or the activated H5 flow in an external mobile browser. Resolve the current attempt before starting a different entry. |
| Scan opens an expired order | Check expires_at and ensure you did not reuse a previous customer's session or cached image. Query the saved order before replacing it. |
| Scanning does not update the display | A scan is not payment and no browser callback is expected. Confirm that the original page polls its own backend and that the backend queries the mapped platform order. |
| Customer paid but status is processing | Keep the same order; let queries and webhooks reconcile the provider outcome. Do not render a fresh charge automatically. |
| Page refresh creates another payment | Persist the active business order and have checkout reuse its unresolved attempt. Query and retrieve the original session before creating a new one. |
| Link expired while the customer was authorizing | Hide the QR code but continue reconciliation. A payment may have started before expiry. |
| Wrong amount appears after scanning | Check server pricing, selected business order, and the platform mapping. Stop checkout and investigate; do not modify the QR content to adjust the price. |
For HTTP errors, duplicate requests, and refunds, follow shared troubleshooting. Keep the request ID, order IDs, expiry, and a sanitized description of the scanning failure for support.