Skip to main content

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)
}
}
RestoredNot restored
The client session, the locked environment, disabledMethods, the monitoring instance idA card form's input, never saved by design
The primary selection, the applied gift cards and their sliceshooks 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.

Plain JSON, and only JSON

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.