Sessions
Follow the session state
A client session can be initialized many times but pays once. sessionState follows it; a watchdog re-checks the expiry every five seconds against a server-corrected clock, so a device running late cannot submit an expired session and one running fast cannot refuse a valid one.
struct CheckoutScreen: View {
@ObservedObject var checkout: PurseHeadlessCheckout
var body: some View {
switch checkout.sessionState {
case .ready: MethodList(checkout: checkout)
case .submitting: ProgressView() // every item publishes DisabledState.submitting meanwhile
case .submitted: Confirmation() // spent: confirm the outcome, then move on
case .error: Confirmation() // spent: /validate answered, a partner step failed after it
case .expired, .terminated: NewSession()
@unknown default: NewSession()
}
}
}
PurseHeadlessCheckout is @MainActor and an ObservableObject: create it, read it and call it from the main actor, and hold it in a @StateObject (or your own view model) so a view rebuild never rebuilds the checkout.
A second submit while one is in flight, or after /validate answered about the payment, is refused with PurseCheckoutError.submitBlocked(state:) (SUBMIT_BLOCKED) and never reaches the PSP.
Refresh the session
The cart changed, or the session expired: mint a new client session on your backend and hand it over without rebuilding the checkout. setSession(_:) is synchronous: it decodes and swaps, it does not call the network.
try checkout.setSession(newWidgetData) // throws sessionExpired if the new blob is already expired
What survives: a primary method whose configuration is unchanged between the two sessions keeps its collected data, its save decision and its place as the split's primary. Everything else is rebuilt: applied gift cards are dropped, and itemGenerations bumps a counter for the items that did not survive, so keying each PaymentElement on (item.id, generation) recreates only those. A blob carrying the same session id is ignored; one handed over while a submit is in flight is refused with submitBlocked(state: .submitting).
Survive process death
iOS may suspend or kill the app while the shopper is in a bank app, PayPal or a 3-D Secure page. Snapshot the checkout, store the snapshot yourself, and rebuild from it.
@MainActor
final class CheckoutModel: ObservableObject {
let checkout: PurseHeadlessCheckout
private static let key = "purse.checkout.state"
init(widgetData: String, hooks: PurseCheckoutHooks) throws {
if let json = UserDefaults.standard.string(forKey: Self.key),
let state = try? CheckoutSavedState.fromJSON(json) {
checkout = try PurseHeadlessCheckout.restore(state, hooks: hooks)
} else {
checkout = try PurseHeadlessCheckout(
widgetData: widgetData,
lockedEnvironment: .production,
hooks: hooks
)
}
}
func snapshot() throws {
UserDefaults.standard.set(try checkout.saveState().toJSON(), forKey: Self.key)
}
func close() {
checkout.teardown()
UserDefaults.standard.removeObject(forKey: Self.key)
}
}
| Restored | Not restored |
|---|---|
The client session, the locked environment, disabledMethods, the monitoring instance id | A card form's input, never saved by design |
| The primary selection, the applied gift cards and their slices | hooks and options: pass the same ones to restore |
Save decisions (register, registerSecondary) | A primary the session no longer offers or that came back disabled, and a slice the new amount cannot honour. Both are skipped silently |
| What the native steps collected |
If a submit had already left the device, the restored sessionState is .submitted and submitPayment() refuses: confirm the payment on your backend instead of letting the shopper pay twice. A return from a redirect after the process was killed reaches the recreated element as PaymentOutcome.returnedFromRedirect(url:), which leads to the same check.
CheckoutSavedState.toJSON() / fromJSON(_:) are the serialization. Put the string wherever you keep restoration state. savedAt lets you decide whether a snapshot is still worth restoring. A snapshot from an unsupported version is refused with PurseSessionError.sessionMalformed. The format is not interchangeable with Android's, which writes its timestamp in milliseconds.
Confirm the payment
A shopper coming back from a redirect tells you they came back, nothing more: the final hop is built server-side and can land on a URL the app cannot read. PaymentOutcome.returnedFromRedirect(url:) is deliberately not an outcome.
In the app, treat the return as pending and show the shopper that the payment is being processed. Confirm the final payment status on your backend: listen to webhooks, or read the session with Get a client session. Your backend, reading the Purse API, is the authority on a settled order.
Tear down
checkout.teardown()
Cancels the expiry watchdog and every background task the checkout started, stops observing app backgrounding, and flushes pending monitoring events; idempotent. isTornDown turns true, and every later call that touches the network or mutates the session throws PurseCheckoutError.checkoutTornDown (CHECKOUT_TORN_DOWN). Call it when the checkout screen is dismissed for good, not when a view disappears behind a push.