Card (Purse vault)
The card method renders the iOS Secure Fields form: card number, expiry, CVV and holder. Card data is tokenized by the Purse vault and never reaches your code.
Setup
| Product | PurseHeadlessCheckout, nothing else |
| Dashboard | A card method whose vault is Purse (vault.vendor = purse) activated on the entity, with any acquiring partner |
| Install | Nothing. CardModule is built into PluginRegistry |
| Project | A RedirectPresenter if you want 3-D Secure to open. The SDK never presents one you did not pass |
| Keys | None in the app. The vault tenant travels in the client session |
A card method hosted by a PSP's own form (Adyen, Stripe, Checkout.com, a pci_proxy vault…) has no native form and is not rendered by this SDK. Configure the method in redirection mode to offer it through the redirection guide.
Render and pay
A PaymentElement is a plain object, not a SwiftUI view: build it once when the shopper selects the method and keep it. Rebuilding it on every body evaluation restarts the form and loses what was typed.
@MainActor
final class Elements {
private var cache: [String: PaymentElement] = [:]
/// The generation changes when `setSession` replaces the item; the key follows it.
func element(
for item: PaymentItem,
in checkout: PurseHeadlessCheckout,
listener: any PaymentElementListener,
redirect: (any RedirectPresenter)?,
appReturnURL: URL? = nil // required for Klarna, ignored by every other method
) -> PaymentElement {
let key = "\(item.id):\(checkout.itemGenerations[item.id] ?? 0)"
if let existing = cache[key] { return existing }
let element = checkout.paymentElement(
for: item, listener: listener, redirect: redirect, appReturnURL: appReturnURL
)
cache[key] = element
return element
}
}
struct CardSection: View {
@ObservedObject var checkout: PurseHeadlessCheckout
let method: PrimaryMethod
@ObservedObject var element: PaymentElement
var body: some View {
VStack(spacing: 16) {
PaymentElementView(element)
Button("Pay") { element.submit() }
.disabled(!element.isReady)
}
.task {
try? checkout.setPrimary(method)
await element.prepare()
}
}
}
An empty or invalid form is never payable: element.isReady and isPaymentFulfilled stay false until every field validates. submit() tokenizes the card, then runs /init and /validate, then presents 3-D Secure through the RedirectPresenter you passed. It is synchronous and returns a Task you may await; calling it again while one is in flight returns the same task rather than starting a second payment.
checkout.schemeAssets(for: method) gives the accepted schemes as (url, label) pairs, to show above the form. The URLs are SVGs on Purse's CDN: loading and rendering them is yours, the SDK ships no images.
Outcomes
| Outcome | When |
|---|---|
completed(ValidationResult) | /validate answered without a redirect. result.status is the API's verdict |
returnedFromRedirect(url:) | The shopper came back from 3-D Secure. Confirm the payment status on your backend |
canceled | The 3-D Secure page was dismissed |
failed(PurseError) | Tokenization refused, /init or /validate failed. error.code says which |
With no redirect: presenter, a /validate that asks for 3-D Secure reports completed instead: the SDK takes it that you will follow the redirect yourself, and checkout.pendingRedirect(for:) hands you the action to follow.
Save the card
When the method allows it (save_token_mode PREFER or PREFER_NO in the dashboard), canBeRegistered is true and the shopper can keep the card for the next session:
if method.canBeRegistered {
try checkout.register(method, registered: true, name: "holiday card")
}
let decision = checkout.registrationOf(method) // readable before and after submit
register throws PurseCheckoutError.walletOperationFailed with METHOD_NOT_REGISTERABLE when the method cannot save cards, or INVALID_TOKEN_NAME / TOKEN_NAME_EXISTS on a bad label (1–25 alphanumeric or space characters, unique across saved tokens). Saving needs a wallet session in the client session: see Saved cards and accounts.
Customize the form
Locale, texts and theme come from the PluginOptions you pass to PurseHeadlessCheckout(widgetData:…, options:), with a PurseTheme built from native UIColor, UIFont and CGFloat, not CSS strings. See Customization.
Troubleshooting
| Symptom | Cause |
|---|---|
| The Pay button never enables | A field is invalid. The form shows which; element.isReady mirrors it |
| The form loses its input as the screen redraws | A new PaymentElement is built on every body evaluation. Cache it on the item's "{id}:{generation}" key |
The form has no height in a VStack | PaymentElementView sizes itself from the partner view; do not give it a .frame(height:) unless you want to override that |
failed with API_ERROR, message creditcard init not successful | /init was refused for this item. Check the method's configuration on the entity |
| The form does not initialize at all | The session carries no vault tenant: the card method is not vaulted by Purse |
3-D Secure never opens, the outcome is completed | No redirect: presenter was passed to paymentElement(for:) |
| A 3-D Secure page refuses to render | Some issuers block embedded web views. The default presenter uses ASWebAuthenticationSession for a GET precisely to avoid this; check you have not forced prefersInAppWebView |