Key concepts
The iOS SDK shares its model with the web Headless Checkout and the Android SDK. This page explains that model in Swift terms: what a checkout holds, how a payment is assembled, and what the SDK decides for you.
One checkout, one client session
A PurseHeadlessCheckout is built from one client session, the widget.data string your backend received when it created the session. The session decides everything the shopper can do: which methods are offered, the amount and currency, whether saved instruments are available, which environment the payment runs in. The SDK never invents a method your Purse configuration does not offer, and never talks to the Purse API with credentials of its own: every call carries the session's own key.
A session can be initialized many times but pays once. sessionState follows it: .ready, .submitting while /validate is in flight, .submitted once spent, .error when /validate answered but a partner step failed after it (spent as well; a transient failure returns to .ready), .expired or .terminated when it is over. A new cart means a new session, handed to the same checkout with setSession(_:).
The checkout is @MainActor and an ObservableObject. Bind to it from SwiftUI, or observe objectWillChange from UIKit; every property below is @Published.
Payment items: primaries, secondaries, tokens
paymentMethods lists the session's payment items. Every item has an id ("purse-creditcard", "paypal-wallet"), a partner, a method and a disabled state.
| Item | Pays | Examples |
|---|---|---|
PrimaryMethod | The remainder of the amount, whatever gift cards left | Card, Apple Pay, PayPal, Klarna, a bank transfer, a BNPL provider |
SecondaryMethod | A slice of the amount, several can stack | Gift cards, loyalty cards, vouchers |
PaymentToken (on paymentTokens) | The remainder, like a primary | A saved card, a saved PayPal account |
SavedSecondaryToken (on savedSecondaryTokens) | A slice, like a secondary | A gift card kept from an earlier session |
paymentMethods holds methods only. Saved instruments live on their own properties, and a method filtered out of the session is excluded entirely rather than listed as disabled, because a merchant never renders those.
A secondary method is not paid with directly: the shopper enters a card number, the SDK asks the issuer for the balance and hands back a SecondaryToken, and that token is what joins the split.
The split and fulfilment
The split is the list of slices submitPayment() will send, one per source: the applied gift card tokens and, unless they cover everything, one primary. remainingAmountToPay is what the primary still has to pay; amountSplit is the list itself. The splitter works in integer cents internally, so a slice never drifts by a rounding error.
setPrimary(_:)orsetPrimaryToken(_:)names the primary. Apple Pay and PayPal do it themselves when the shopper approves.applySecondary(_:amount:)draws a gift card into the split, as much as the order needs or a capped slice.
Fulfilment is the second gate. A payment is fulfilled when the split covers the amount and every form in it is valid. Today only the card form is a form the SDK validates: isPaymentFulfilled stays false while it is incomplete, and submitPayment() refuses with elementNotFulfilled. Wallets, Klarna's widget and redirection methods have nothing to validate and report nothing.
The payment element
A payment element is one method's own step: the secure card form, Apple Pay's button, PayPal's button, Klarna's widget, or nothing at all for a redirection method. checkout.paymentElement(for:listener:redirect:appReturnURL:) creates it, PaymentElementView(element) places it in SwiftUI, and element.submit() pays.
Three flags describe it:
| Flag | Meaning |
|---|---|
hasUI | There is something to place. false for redirection methods, saved cards that need no security code, saved PayPal accounts, and technically disabled methods |
selfSubmitting | The element's own UI is the Pay button. true for Apple Pay and PayPal; never render a Pay button of yours next to them |
isReady | The step is usable: the form is valid, the widget loaded, the wallet is available. Enable your Pay button on it |
The element is a UIView factory, because that is the only currency the partner SDKs share: Secure Fields returns views, Klarna wants a container, PassKit and PayPal each ship a button. PaymentElementView wraps it for SwiftUI and sizes it correctly; give it an explicit height when you need several partner buttons in a row to match, since each vendor's button has a different intrinsic height.
Creating an element can cost a POST /init: Klarna needs its client token to render, Apple Pay its payment request. A redirection method's /init runs later, at submit. Create the element when the shopper selects the method, not for every row.
Outcomes
Every attempt ends in exactly one PaymentOutcome on the element's listener:
| Outcome | Meaning | What you do |
|---|---|---|
.completed(result) | /validate answered directly. status is the API's verdict | isSuccess shows success; anything else is a declined payment, not an error |
.returnedFromRedirect(url:) | The shopper came back from 3-D Secure, a PSP page or PayPal | Not an outcome. Confirm the payment status on your backend |
.canceled | The shopper backed out of a sheet or a page | Nothing changed; let them try again |
.failed(error) | A PurseError with a stable code | Branch on the code, see Error handling |
Why a return is not an outcome: a real 3-D Secure challenge ends on a page the bank constructs, which the app cannot read safely. The SDK therefore reports the return, and your backend confirms the final payment status: listen to webhooks, or read the session with Get a client session.
If a /validate request left the device and no answer came back, the payment may well exist. That surfaces as outcomeUnknown on the underlying PaymentAPIError, and showing it to a shopper as a failed payment is the worst mistake this screen can make. Resolve it the same way you resolve a redirect return: confirm the payment status on your backend.
Method states
A method the session offers but that cannot be used right now stays listed with a disabled state: a code, a displayable message, and five behavioural flags that tell you how to render it and whether it will come back. The amount-driven states (minAmountNotMet, maxAmountExceeded, paymentFulfilled) move as the split moves; the technical ones (tokenInitFailed, partnerUIFatalError) are final for the session; a partner's recoverable failure (partnerUIRecoverableError) dims a method until the partner reports it ready again, and a plugin that refuses the device (pluginIncompatible) removes the method from the list; submitting locks every method while a payment is in flight. The complete table of codes, behaviours and expected reactions is the Method states reference.
A state's message can quote amounts, which is why only its code is ever reported to Purse's monitoring.
Redirects stay in the app
3-D Secure challenges and PSP pages open through a RedirectPresenter the SDK provides. A GET redirect goes to ASWebAuthenticationSession, Safari's own sheet, only when the browser can actually observe the return: a custom scheme always, a Universal Link on iOS 17.4 and later. Otherwise it goes to an in-app WKWebView restricted to https, which watches every navigation itself and works on every supported version. A POST redirect always uses the web view, because no system browser can POST.
Klarna's widget can open a bank or identity app and must return to a URL you supply: unlike Android, iOS has no application id to derive one from, so you pass appReturnURL explicitly and declare the matching scheme.
Your onRedirection hook can take a redirect over, at the price of confirming the outcome yourself when the shopper returns.
Errors and web parity
Every error conforms to PurseError and carries a PurseErrorCode shared with the web and Android SDKs, so a merchant on several platforms handles one vocabulary. PurseError is a protocol rather than a base class, because Swift errors are values and a caller should be able to switch over PurseCheckoutError, PaymentAPIError and PurseSessionError as distinct types.
Three codes exist only on the native SDKs, because the situations are refused here where the web SDK stays silent: SUBMIT_BLOCKED (a second submit), CHECKOUT_TORN_DOWN (a call after teardown()) and PARTNER_UI_ERROR (a partner SDK failure as a typed error). Codes about DOM rendering exist only on the web.
Other deliberate differences from the web SDK:
| Topic | Web | iOS | Why |
|---|---|---|---|
| Fulfilment | setAsPrimarySource refuses an unfulfilled element | Reactive isPaymentFulfilled; a method is selectable before its form is filled | Fits an observable UI |
| Second submit | Silently ignored, logged | Throws submitBlocked | An app needs a code to react to |
teardown() | Instance stays usable | Every later call that touches the network or changes the session throws checkoutTornDown | Lifecycle is explicit on a device |
| Payment confirmation after a redirect | Your success page | Your backend, from webhooks or Get a client session | The app returns to the launching screen |
| Process death | Not applicable | saveState() / restore(_:) | iOS may terminate a suspended app during a bank step |
| Session refresh | Resets the split | Keeps an unchanged primary selected | A cart change should not lose the shopper's choice |
| Amounts | JavaScript numbers | Decimal parsed from the JSON's literal text | A binary float must never touch money |
A partner configured in your Purse dashboard is offered to every platform by the same client session. The Supported payment methods table says which methods the iOS SDK renders natively.