Connect your app to checkout
Use this page to connect the payment-method tutorials to your application. It defines the app-facing endpoints, shows how to call them, and supplies the status refresh used by the SDK callbacks. These are endpoints you implement on your backend, not endpoints at pay.appconnect.cn.
1. Implement the app-facing endpoints
Follow Build your payment backend for server persistence and the Payment API calls behind these routes.
| Your endpoint | Request | Required backend behavior |
|---|---|---|
POST /api/checkout | cartId, paymentEntry | Authenticate, load the owned cart, calculate price, reuse an unresolved attempt, create/recover its platform session, and return the response below. |
GET /api/orders/:businessOrderId | Saved business reference | Check ownership, query the mapped platform order, reconcile fulfillment, return the status below. |
POST /api/orders/:businessOrderId/refunds | Your refund reason/selected items | Authorize the caller, calculate the permitted amount, persist and submit the platform refund. |
GET /api/refunds/:businessRefundId | Your saved refund reference | Check ownership and return the latest mapped platform refund statuses. |
Native requests use your existing customer session token. Browser examples below assume a same-origin session cookie plus your backend's CSRF token. These customer credentials are different from your server-only Payment API key. Return Cache-Control: no-store for checkout and status responses.
A successful checkout response has this application-owned shape:
{
"businessOrderId": "shop_order_1042",
"paymentOrderId": "ord_demo_001",
"paymentStatus": "pending",
"invocation": {
"kind": "sdk_string",
"sdk_string": "SIGNED_STRING_FROM_PLATFORM",
"expires_at": "2030-01-01T10:15:00+08:00"
}
}
Forward the actual invocation unchanged. A null invocation means it is not ready. A paid order should go to the status screen rather than relaunching payment. Your order endpoint returns:
{
"businessOrderId": "shop_order_1042",
"paymentStatus": "paid",
"fulfillmentStatus": "delivered",
"amountMinor": 1000,
"currency": "CNY"
}
Use pending, delivered, or a suitable application-specific fulfillment state. Never accept a client's claim that an order is paid. Only the backend can generate the confirmed state after validating the platform result.
Checkpoint: call these routes with a signed-in test customer. They return only that customer's records and never expose the Payment API key.
2. Connect Android
Copy CheckoutApi.kt into your app and change its package to your package name. It uses Android's built-in HTTP and JSON APIs, runs network calls off the main thread, and delivers results on the main thread. Add the INTERNET permission if it is not present.
Create one instance in your checkout coordinator, using your own HTTPS API origin and customer token provider:
val checkoutApi = CheckoutApi("https://api.example.com") { signedInCustomerToken }
val pending = getSharedPreferences("checkout", MODE_PRIVATE)
fun beginCheckout(cartId: String, entry: String, launch: (org.json.JSONObject) -> Unit) {
val body = org.json.JSONObject().put("cartId", cartId).put("paymentEntry", entry)
checkoutApi.request("/api/checkout", body) { outcome ->
outcome.onSuccess { checkout ->
val orderId = checkout.getString("businessOrderId")
// Commit before leaving this app. A single foreground checkout is shown here.
if (!pending.edit().putString("businessOrderId", orderId).commit()) {
showCheckoutMessage("Cannot save checkout. Open order history before paying.")
return@onSuccess
}
if (checkout.getString("paymentStatus") == "paid") {
refreshPendingOrder()
} else {
val invocation = checkout.optJSONObject("invocation")
if (invocation == null) showCheckoutMessage("Preparing payment. Check your order shortly.")
else if (checkout.getString("paymentStatus") in listOf("pending", "processing")) {
runCatching { launch(invocation) }.onFailure {
showCheckoutMessage("Cannot open payment. Check the saved order.")
}
} else refreshPendingOrder()
}
}.onFailure {
showCheckoutMessage("Could not read checkout. Check the existing order before trying again.")
}
}
}
fun refreshPendingOrder() {
val orderId = pending.getString("businessOrderId", null) ?: return
checkoutApi.request("/api/orders/" + android.net.Uri.encode(orderId)) { outcome ->
outcome.onSuccess { order ->
showCheckoutMessage(when (order.getString("paymentStatus")) {
"paid" -> if (order.optString("fulfillmentStatus") == "delivered")
"Purchase complete" else "Payment received. Preparing your purchase."
"failed" -> "Payment unsuccessful. View your order before retrying."
"closed" -> "This payment is closed."
else -> "Checking payment. You can return to your order later."
})
}.onFailure { showCheckoutMessage("Cannot check payment yet. Your order is saved.") }
}
}
These functions belong in your Activity or a coordinator adapted to its lifecycle. signedInCustomerToken comes from your existing sign-in session; showCheckoutMessage displays text in your UI. The payment decisions and HTTP calls are shown above. Keep the coordinator alive while a request is active and call CheckoutApi.close() when it is permanently disposed. Avoid updating a destroyed Activity.
Call beginCheckout(cartId, "wechat_app", ::launchWechat) or the Alipay equivalent using the launcher in its guide. Call refreshPendingOrder() from the SDK result, your Check payment button, and onResume. Coalesce overlapping status requests. Clear the local reference only when the customer has seen a definitive result; your backend retains all orders.
Validate native invocation expiry
Copy PaymentInvocation.kt next to CheckoutApi.kt. Call requireUsableInvocation(invocation, expectedKind) before launching either native SDK. It validates the kind and RFC 3339 expiry without requiring Android API 26. Invalid or expired instructions lead back to the saved order, not a new charge.
3. Connect iOS
Copy CheckoutAPI.swift into your app target. Create a long-lived instance with your application's HTTPS origin and signed-in customer's token. This helper's callback runs on the main queue.
let checkoutAPI = CheckoutAPI(origin: URL(string: "https://api.example.com")!) {
signedInCustomerToken
}
func beginCheckout(cartId: String, entry: String,
launch: @escaping ([String: Any]) -> Void) {
checkoutAPI.request(path: "/api/checkout", body: ["cartId": cartId, "paymentEntry": entry]) { result in
switch result {
case .success(let checkout):
guard let orderId = checkout["businessOrderId"] as? String else { return }
UserDefaults.standard.set(orderId, forKey: "pendingBusinessOrderId")
if checkout["paymentStatus"] as? String == "paid" {
refreshPendingOrder()
} else if ["pending", "processing"].contains(checkout["paymentStatus"] as? String ?? ""),
let invocation = checkout["invocation"] as? [String: Any] {
launch(invocation)
} else {
showCheckoutMessage("Preparing payment. Check your order shortly.")
}
case .failure:
showCheckoutMessage("Could not read checkout. Check your existing order before trying again.")
}
}
}
func refreshPendingOrder() {
guard let id = UserDefaults.standard.string(forKey: "pendingBusinessOrderId"),
let segment = id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { return }
checkoutAPI.request(path: "/api/orders/" + segment) { result in
switch result {
case .success(let order):
let status = order["paymentStatus"] as? String
let delivered = order["fulfillmentStatus"] as? String == "delivered"
showCheckoutMessage(status == "paid"
? (delivered ? "Purchase complete" : "Payment received. Preparing your purchase.")
: (status == "closed" ? "This payment is closed." : "Check your order for the latest payment result."))
case .failure:
showCheckoutMessage("Cannot check payment yet. Your order is saved.")
}
}
}
signedInCustomerToken and showCheckoutMessage are your existing authentication and UI integration. Put these functions in your checkout coordinator and adapt self captures accordingly. The SDK guides show Objective-C at the SDK boundary; Swift projects can put those calls in an Objective-C bridge class and pass the returned invocation as an NSDictionary, or use the imported Swift signatures from the same SDK.
Trigger the same refresh from your SDK completion, Check payment button, and scene/app foreground callback. Observe the PaymentNeedsRefresh notification emitted in the iOS SDK examples. In an Objective-C app, implement the same two HTTPS routes with NSURLSession, preserving the request/response shapes above.
Refresh after an iOS callback
In a coordinator class that owns refreshPendingOrder, retain the observer token and register it when checkout starts:
private var paymentObserver: NSObjectProtocol?
func observePaymentReturns() {
guard paymentObserver == nil else { return }
paymentObserver = NotificationCenter.default.addObserver(
forName: Notification.Name("PaymentNeedsRefresh"), object: nil, queue: .main
) { [weak self] _ in self?.refreshPendingOrder() }
}
deinit {
if let paymentObserver = paymentObserver { NotificationCenter.default.removeObserver(paymentObserver) }
}
Call the same method from your app/scene foreground handler. This recovers returns received before the observer was registered. Use an in-flight guard in the coordinator so callback and foreground events share one status request.
For an Objective-C SDK bridge, this method validates expiry before passing an invocation to the SDK:
- (BOOL)isUsableInvocation:(NSDictionary *)invocation kind:(NSString *)kind {
if (![invocation[@"kind"] isEqualToString:kind] ||
![invocation[@"expires_at"] isKindOfClass:[NSString class]]) return NO;
NSISO8601DateFormatter *formatter = [[NSISO8601DateFormatter alloc] init];
formatter.formatOptions = NSISO8601DateFormatWithInternetDateTime | NSISO8601DateFormatWithFractionalSeconds;
NSDate *expiry = [formatter dateFromString:invocation[@"expires_at"]];
if (!expiry) {
formatter.formatOptions = NSISO8601DateFormatWithInternetDateTime;
expiry = [formatter dateFromString:invocation[@"expires_at"]];
}
return expiry && [expiry timeIntervalSinceNow] > 0;
}
Put isUsableInvocation:kind: in the same bridge class as your launch methods. The examples use iOS 12 or later for this common client layer. SDK dispatch and callback methods must run on the main queue; backend work remains asynchronous.
4. Connect a browser checkout
Copy checkout-browser.mjs into your frontend source directory. Its paths are relative to your application origin. Your authenticated backend renders the CSRF token into a meta tag; replace SERVER_RENDERED_TOKEN server-side, with HTML escaping:
<meta name="csrf-token" content="SERVER_RENDERED_TOKEN">
<button id="pay" type="button">Pay</button>
<p id="payment-status" role="status"></p>
import { checkoutRequest } from './checkout-browser.mjs';
const button = document.getElementById('pay');
const status = document.getElementById('payment-status');
button.addEventListener('click', async () => {
button.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_page' },
});
sessionStorage.setItem('pendingBusinessOrderId', checkout.businessOrderId);
// Call the redirect or QR handler from the selected method guide here.
status.textContent = checkout.paymentStatus === 'paid' ? 'Payment received' : 'Continue to payment';
} catch {
status.textContent = 'Could not prepare payment. Check your existing order.';
} finally {
button.disabled = false;
}
});
The method-specific pages replace the handler comment with the actual redirect or QR rendering. Store your pending-order mapping on the backend too: a new tab, different browser, or scanning device may not share session storage.
5. Build the return and order-status page
Use this on the browser return page or beneath a QR code. Render a button with id="check-payment" and the status element above. The authenticated backend should resolve the order when session storage is missing; do not expose an order just because its ID appears in a return URL.
import { watchOrder } from './checkout-browser.mjs';
const status = document.getElementById('payment-status');
const orderId = sessionStorage.getItem('pendingBusinessOrderId');
let checking = false;
const controller = new AbortController();
window.addEventListener('pageshow', event => { if (event.persisted) window.location.reload(); });
window.addEventListener('pagehide', () => controller.abort(), { once: true });
async function checkPayment() {
if (checking || !orderId || controller.signal.aborted) return;
checking = true;
try {
const result = await watchOrder(orderId, order => {
status.textContent = order.paymentStatus === 'paid'
? (order.fulfillmentStatus === 'delivered' ? 'Purchase complete' : 'Payment received. Preparing your purchase.')
: `Payment status: ${order.paymentStatus}`;
}, { signal: controller.signal });
if (!result) status.textContent = 'Still confirming. Check again or return to order history.';
} catch {
if (!controller.signal.aborted) status.textContent = 'Cannot check payment yet. Your order is saved.';
} finally { checking = false; }
}
document.getElementById('check-payment').addEventListener('click', checkPayment);
if (orderId) checkPayment();
else status.textContent = 'Sign in and open this purchase from your order history.';
The helper stops its short polling window after a definitive financial state or about a minute. If payment is received but fulfillment is still pending, keep a status action and let the backend finish delivery. A failed network request never creates another payment. Your webhook worker and reconciliation job continue regardless of the foreground screen.
Checkpoint: close the provider app without a callback, reopen your app, and confirm that the saved order can still be retrieved and fulfilled once.