Skip to main content

Alipay QR code

Use alipay_qr to display a QR code that opens the Alipay cashier. Your backend creates the order, your checkout encodes the returned cashier URL, and the customer scans it to complete payment.

This entry currently uses the same hosted checkout mechanism as alipay_page. Its invocation is redirect_url. It still supports a QR checkout experience: generate the code from invocation.redirect_url. Do not wait for a qr_url field or call a separate Alipay precreate API.

1. Prepare your QR checkout​

Have alipay_qr enabled for your platform application. Use a screen that the customer can scan from a second device. Provide a QR renderer and an authenticated order-status endpoint in your backend. No Alipay SDK is required on the screen displaying the code.

Keep the business order associated with the original checkout. The device that scans the code may not have your website session, so use webhooks and status queries on the original device to confirm payment.

Setup checklist​

  1. Activate alipay_qr and keep the platform API key on your backend. The display page requires a QR renderer, not the Alipay native SDK.
  2. Create an authenticated desktop/display checkout with a QR canvas, order amount, expiry, status text, and Check payment action. The customer will scan with Alipay on another device.
  3. Implement the checkout/status endpoints in Connect your app to checkout, and copy checkout-browser.mjs into your frontend source.
  4. Persist the business order and platform mapping on your backend. The scanning phone may have no session for your website; confirmation belongs to the original authenticated checkout and webhook worker.
  5. Install the QR renderer in step 3. Use the full cashier URL as QR content, without shortening or changing the signed provider URL.

Checkpoint: your checkout can recover the same unpaid order after a refresh, and you have a second device ready to scan. A cashier page on the scanning device is expected behavior.

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": "alipay_qr",
"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.

Use the exact signed HTTPS URL from data.invocation.redirect_url. Your backend should validate its expected provider destination before passing it to the frontend.

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 showAlipayQr(invocation) {
const canvas = document.getElementById('payment-qr');
canvas.hidden = true;
clearTimeout(qrExpiryTimer);
const value = requireInvocation(invocation, 'redirect_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();
}

Display Scan with Alipay to pay, the order amount, and the expiry. Signed cashier URLs can be long, so allow the QR renderer to choose its version and preserve its natural size. Do not truncate the URL, remove parameters, add tracking parameters, or shrink the code until its modules blur.

After rendering, poll your own backend for the saved business order. The scanning device opens the cashier and completes payment there; the original page should update from the server's confirmed result. A same-device Open Alipay checkout link may use the same returned URL, if that experience is appropriate for your application.

Hide the QR code after payment, closure, or expiry. An expired code does not prove that an in-flight payment failed. Continue checking the original order.

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: 'alipay_qr' },
});
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 showAlipayQr(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​

Scan the generated cashier-link QR code using Alipay on a second device. Verify the cashier's order and amount, complete payment, and confirm the original screen updates through its backend. Repeat cancellation, expiry, scanning without your website login, and closing the original checkout tab.

7. Common problems and how to fix them​

SymptomCheck and fix
There is no qr_url in the responseThis entry returns redirect_url. Encode that cashier URL, as shown above. It is a valid QR presentation of the hosted checkout.
Scan opens a cashier page instead of a direct payment sheetThat is the expected cashier-link flow. The customer completes payment in the cashier; the display device watches the original platform order.
QR is too dense or hard to scanSigned URLs can be long. Let the renderer choose the version, keep at least its quiet margin, use integer module scale, and display enough physical area. Do not truncate, recompress, or manually remove signed fields.
Scanner cannot recognize the codeTest with Alipay's scanner, confirm sufficient contrast and resolution, and compare the decoded text with the exact returned URL in a controlled test. Do not send live URLs to an external QR-debugging site.
URL has &amp; text or a signature errorCheck that your backend returns JSON normally and the frontend encodes the parsed string. Do not HTML-escape the URL into QR content or apply an extra URL-encoding pass.
Scanning phone is not signed in to your websiteDo not require that device to confirm your order. The original authenticated screen and your server track the platform order; avoid placing session tokens in the QR content.
QR opens a stale/closed paymentRead the current order and invocation expiry. Remove the old QR and recover the original result before offering a later payment attempt.
Original screen never receives a callbackIt may never receive one. Verify its status polling and the backend webhook/reconciliation path.
A page reload creates a second QR/orderReload the existing business order and reuse its session. Disable duplicate launches and keep uniqueness constraints on unresolved attempts.
Can the same link be a button instead?Yes. A same-device checkout can navigate to the returned cashier URL. Keep the same order and unchanged URL. The QR presentation changes the customer journey, not the financial identity.
Scan succeeded, but the customer is not creditedScanning only opens checkout. Retrieve the platform order and then inspect fulfillment. Only confirmed payment can authorize delivery.

Use shared troubleshooting for platform/API/refund failures. A support report should include the platform request ID, order IDs, timestamp, invocation kind and expiry, and device/scanner version; do not include a full signed cashier URL in public logs.