Getting started
This tutorial takes you from an empty Xcode project to a card payment in the Purse sandbox. At the end you will have a screen that lists the session's payment methods, renders the secure card form, pays, follows 3-D Secure and shows the outcome.
- Prerequisites: a sandbox client session and an empty SwiftUI project.
- Install the SDK.
- Create the checkout in an observable model.
- List the payment methods.
- Render the card form and pay.
- Read the outcome, including after 3-D Secure.
- Check the payment in the sandbox.
Card only, no partner product. Apple Pay, PayPal, Klarna, redirection methods, gift cards and saved methods each have their own guide under Payment methods.
Step 1 - Prerequisites
A sandbox client session. The SDK never talks to the Purse API on its own behalf: your backend creates a client session and hands the app the base64 widget.data string it gets back. Follow Your first payment with Purse against https://api.purse-sandbox.com with your sandbox credentials, using an entity where a card method vaulted by Purse is activated. Keep the widget.data value: for this tutorial you paste it into the app.
An empty project. In Xcode 16 or later, create an App project with the SwiftUI interface and set the minimum deployment target to iOS 16.0.
Step 2 - Install
The SDK is a Swift package. One library product carries the engine, the payment elements and the card and redirection methods. Apple Pay, Klarna and PayPal each bring a partner SDK, so they are separate products you link only if you offer them. This tutorial needs only the first.
In Xcode, choose File ▸ Add Package Dependencies, enter the repository URL and add the PurseHeadlessCheckout library. In a package manifest of your own:
// Package.swift
dependencies: [
.package(url: "https://github.com/UpStreamPay/purse-headless-checkout-ios.git", from: "SDK_VERSION")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "PurseHeadlessCheckout", package: "purse-headless-checkout-ios")
])
]
SDK_VERSION is the latest version on the releases page. The package pulls in Purse's Secure Fields for the card form; you add nothing else.
Take the package over https://. An ssh:// or [email protected]: URL authenticates with a key whatever the repository's visibility, and a CI runner does not have one.
Step 3 - Create the checkout
Hold the checkout in an observable model so a view redraw never rebuilds it. The model also holds the selected method's element and the outcome to show, and serves as the element's listener in step 6.
import Combine
import PurseHeadlessCheckout
import SwiftUI
@MainActor
final class CheckoutModel: ObservableObject {
enum State {
case idle, authorized, declined, pending
case failed(any Error)
}
let checkout: PurseHeadlessCheckout
@Published private(set) var element: PaymentElement?
@Published var state: State = .idle
// Presents 3-D Secure; see step 6.
let redirectPresenter = WebAuthRedirectPresenter(
policy: RedirectPolicy(returnDestination: .customScheme("com.example.app.redirect"))
)
private var cancellables: Set<AnyCancellable> = []
private var elementSubscription: AnyCancellable?
init(widgetData: String) throws {
checkout = try PurseHeadlessCheckout(
widgetData: widgetData, // from your backend
lockedEnvironment: .sandbox // release builds pass .production
)
// The checkout is a nested ObservableObject: forward its changes to the views.
checkout.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
}
func close() {
checkout.teardown()
}
}
The initializer decodes the session and throws PurseSessionError.sessionMalformed if the string is not a client session. lockedEnvironment pins the environment the SDK talks to: a release build must pass .production, so that a session minted against the wrong environment cannot redirect the app. teardown() releases the SDK's resources when the screen is dismissed for good, and every later call that touches the network or changes the session throws checkoutTornDown. Call it explicitly: a @MainActor class cannot call it from deinit.
PurseHeadlessCheckout is @MainActor. Create it, read it and call it from the main actor; its network calls are async and run off the main thread by construction.
Create the model when the app starts. Its initializer throws, so keep it optional and show the error if the session is invalid. Paste your widget.data string in place of the placeholder; a real app receives it from its backend.
@main
struct CheckoutApp: App {
@State private var model: CheckoutModel?
@State private var sessionError: (any Error)?
var body: some Scene {
WindowGroup {
if let model {
CheckoutScreen(model: model)
.onDisappear { model.close() }
} else if let sessionError {
Text("Invalid session: \(sessionError.localizedDescription)")
} else {
ProgressView().task {
do { model = try CheckoutModel(widgetData: widgetData) }
catch { sessionError = error }
}
}
}
}
}
private let widgetData = "eyJ...." // the client session's widget.data
Step 4 - List the payment methods
State is published, so a SwiftUI view observing the model redraws itself:
struct CheckoutScreen: View {
@ObservedObject var model: CheckoutModel
var body: some View {
List {
Section("Pay with") {
ForEach(model.checkout.paymentMethods, id: \.id) { item in
Button {
model.select(item) // step 5
} label: {
HStack {
Text(label(for: item))
Spacer()
if item.id == model.element?.item.id { Image(systemName: "checkmark") }
}
}
.disabled(item.disabled?.isTechnicallyDisabled == true)
}
}
// The selected method's form and Pay button: step 5
}
}
private func label(for item: PaymentItem) -> String {
if case .creditCard = item.method { return "Bank card" }
return item.method.wireValue
}
}
paymentMethods holds the session's methods. Saved cards live on paymentTokens and saved gift cards on savedSecondaryTokens, so a screen that offers them reads those too. A method the session offers but that cannot be used right now stays in the list with a disabled state you can render; see Method states.
Name a method from its method rather than its partner: a shopper picks "Bank card", not the acquirer routing it.
Step 5 - Render the card form and pay
Selecting a method is what creates its element, because creating one can cost a POST /init. Tell the checkout which method is the primary, build the element, and prepare it:
extension CheckoutModel {
func select(_ item: PaymentItem) {
guard case let .primaryMethod(method) = item, item.id != element?.item.id else { return }
do {
try checkout.setPrimary(method)
} catch {
state = .failed(error)
return
}
let element = checkout.paymentElement(for: item, listener: self, redirect: redirectPresenter)
// isReady is published on the element: forward it so the Pay button updates.
elementSubscription = element.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
self.element = element
Task { await element.prepare() }
}
}
elementSubscription is private to the class, so put this extension in the same file as CheckoutModel. Then place the element in CheckoutScreen, under the method list:
if let element = model.element {
Section {
if element.hasUI {
PaymentElementView(element) // the secure card form
.id(element.item.id)
}
if !element.selfSubmitting {
Button("Pay") { element.submit() }
.disabled(!element.isReady)
}
}
}
The card form is rendered by Purse's Secure Fields: the card number, expiry and security code never reach your code, and your app stays outside PCI scope. isReady turns true when the form is valid, which is what your Pay button should gate on. submit() runs /validate and follows whatever the API asks for next.
hasUI is false for methods with nothing to draw, such as a redirection method or a saved card that needs no security code. selfSubmitting is true for Apple Pay and PayPal, which bring their own Pay button; never draw yours next to them.
Step 6 - Read the outcome
Every attempt ends in exactly one outcome on the listener you passed to paymentElement(for:listener:redirect:):
extension CheckoutModel: PaymentElementListener {
func onReady(_ ready: Bool) { }
func onRequiresAction() {
// A 3-D Secure challenge or a PSP page is about to open.
}
func onOutcome(_ outcome: PaymentOutcome) {
switch outcome {
case let .completed(result):
state = result.isSuccess ? .authorized : .declined
case .returnedFromRedirect:
// Coming back is not proof of payment: wait for your backend.
state = .pending
case .canceled:
state = .idle // nothing happened, let them retry
case let .failed(error):
state = .failed(error) // error.code is stable; see Error handling
@unknown default:
state = .idle
}
}
func onPaymentId(_ paymentId: String) { }
}
Show state on your own result UI. 3-D Secure goes through the redirectPresenter declared on the model in step 3, which select(_:) passes when it builds the element. Its policy returns to the custom scheme com.example.app.redirect.
Declare that scheme in your Info.plist under CFBundleURLTypes. A GET challenge opens in Safari's own sheet when the return can be observed there, and in an in-app web view otherwise; a POST challenge always uses the web view, because no system browser can POST. The web view is presented from the key window's root view controller, which suits a SwiftUI app; pass presentingViewController: to present it from another one. See Redirection for every option.
A real 3-D Secure challenge ends on a page the bank constructs, which your app cannot read safely. Treat .returnedFromRedirect as pending, and confirm the final payment status on your backend: listen to webhooks, or read the session with Get a client session.
Step 7 - Check the payment
Open the Purse dashboard on the sandbox entity you used and find the payment. An authorized card payment shows one AUTHORIZE transaction, and an auto-captured one also shows a CAPTURE linked to it.
In the app, the PaymentOutcome on your listener is the signal to update the screen. Before you tell a shopper the order is confirmed, confirm the payment status on your backend with webhooks or Get a client session.
Where to go next
| You want to | Read |
|---|---|
| Offer Apple Pay, PayPal, Klarna or a PSP page | Payment methods |
| Accept gift cards alongside a card | Gift cards |
| Survive process death during a bank step | Sessions |
| Style the card form | Customization |
| Understand the model | Key concepts |
| Handle every error code | Error handling |