iOS integration (Swift)
Who this is for
Native iOS apps (Swift / SwiftUI or UIKit) that need to show the amount due and collection details in their own UI and refresh the order state once the buyer has paid. The example uses URLSession + Codable, with no third-party dependency.
Before you start
- You have an API KEY (see Get started).
- You have configured a collection address and set it active.
- You have your own server. The API KEY does not go into the app.
The KEY cannot live inside an IPA
iOS bundles can be decompiled, and strings in Info.plist or the binary are not safe. Order requests from the app go to your own server.
Install
No extra dependency is needed — the built-in URLSession is enough. Use SwiftUI for the UI as you prefer.
Minimal working example
1. Order creation on your server
Shown in a Swift server style (Vapor, or any backend); the core is "call the Open API with the KEY":
import Foundation
func createOrder(orderNo: String, amount: String) async throws -> Data {
var req = URLRequest(url: URL(string: "\(ProcessInfo.processInfo.environment["API_BASE"]!)/api/pay/open/createOrder")!)
req.httpMethod = "POST"
req.setValue(ProcessInfo.processInfo.environment["PAY_API_KEY"], forHTTPHeaderField: "X-Pay-Api-Key")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"merchantOrderNo": orderNo, // idempotency key
"chainId": 2,
"tokenId": 1,
"amount": amount, // decimal string in token units
"subject": "Order \(orderNo)",
"notifyUrl": "https://your-shop.com/pay/notify",
])
let (data, _) = try await URLSession.shared.data(for: req)
return data
}2. App side: only talk to your own backend
struct CheckoutRequest: Codable { let orderNo: String; let amount: String }
struct CheckoutResponse: Codable {
let code: Int
let orderNo: String?
let payAddress: String?
let payAmountDisplay: String?
let payAmountMinimal: String?
let chainKey: String?
let msg: String?
}
struct StatusResponse: Codable {
let code: Int
let status: Int // 1..6; only 3/4 mean paid
let statusText: String?
let txHash: String?
}
enum ShopAPI {
static func checkout(_ body: CheckoutRequest) async throws -> CheckoutResponse {
var req = URLRequest(url: URL(string: "https://your-shop.com/api/checkout")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body) // no KEY here
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(CheckoutResponse.self, from: data)
}
static func status(orderNo: String) async throws -> StatusResponse {
let url = URL(string: "https://your-shop.com/api/order-status?orderNo=\(orderNo)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(StatusResponse.self, from: data)
}
}@MainActor
final class CheckoutViewModel: ObservableObject {
enum State { case loading, ready(CheckoutResponse), pending(CheckoutResponse),
paid(CheckoutResponse), error(String) }
@Published var state: State = .loading
func start(orderNo: String, amount: String) async {
do {
let o = try await ShopAPI.checkout(.init(orderNo: orderNo, amount: amount))
guard o.code == 0 else { state = .error(o.msg ?? "create failed"); return }
state = .ready(o)
while true {
try await Task.sleep(for: .seconds(3))
let s = try await ShopAPI.status(orderNo: o.orderNo ?? orderNo)
switch s.status {
case 1: state = .ready(o) // waiting for payment
case 2: state = .pending(o) // submitted, awaiting confirmations
case 3, 4: state = .paid(o); return
case 5: state = .error("Order expired"); return
case 6: state = .error("Manual review required — contact support"); return
default: break
}
}
} catch {
state = .error(error.localizedDescription)
}
}
}You can also open the platform's payment counter directly:
{cashierBaseUrl}/pay/cashier/m/{merchantNo}, usingUIApplication.shared.open. Both values come from the merchant-sideGET /api/pay/getMyProfile.
Callback handling
Callbacks are received by your server. A Swift server-side verification example:
import CryptoKit
func verify(rawBody: String, ts: String, sig: String, apiKey: String) -> Bool {
let key = SymmetricKey(data: Data(apiKey.utf8))
var mac = HMAC<SHA256>.init(key: key)
mac.update(data: Data(ts.utf8)) // timestamp + raw body, string concatenation
mac.update(data: Data(rawBody.utf8))
let want = mac.finalize().map { String(format: "%02x", $0) }.joined()
// constant-time comparison
return constantTimeEqual(want, sig)
}Handling notes:
- Idempotent: callbacks retry; deduplicate on
merchantOrderNo. - Return fast: about a 10-second timeout; move slow logic to a background task.
- Query is authoritative: verify with Query order first.
Wallet hand-off and app-to-app jumps
- Most crypto wallets on iOS support universal links or a custom scheme;
UIApplication.shared.openwill try to open the wallet. - Declare the schemes you want to probe in
LSApplicationQueriesSchemesinInfo.plistbefore usingcanOpenURL. - Always show the QR code as a fallback, so users without a wallet can pay from another device.
FAQ
| Question | Answer |
|---|---|
Can the KEY go in Info.plist / Keychain? | No. The app does not hold the KEY; orders go through your backend |
| Polling stops when iOS suspends the app | Pause polling on background, and on scenePhase returning to foreground resume and query once immediately |
| Can I embed checkout in a WebView? | Possible, but opening the platform checkout in the system browser is simpler |
| How do I know it is paid? | Only states 3 / 4 — never compare numeric values |
| Will duplicate requests create duplicate orders? | No — the same merchantOrderNo is idempotently reused |