Skip to main content

Error handling

Every SDK error conforms to PurseError and carries a stable code (PurseErrorCode), shared with the web SDK's error codes. Write your recovery logic on the code, never on the message. The complete list, code by code with the expected reaction, is the Error codes reference; a method that is listed but cannot be used is not an error but a method state.

PurseError is a protocol, not a base class: PurseCheckoutError, PurseSessionError and PartnerUIError stay distinct types you can switch over, and every one of them exposes code, message, underlying and documentationLink.

Where errors surface​

PathSurface
element.submit()PaymentOutcome.failed(error) on the element's listener. Most integrations only need this
checkout.submitPayment() and every other async throws functionThe error is thrown
setPrimary, applySecondary, register, setSession, … (synchronous throws)The error is thrown
A partner SDK's own failurePartnerUIError (PARTNER_UI_ERROR), the partner's error as its underlying; the method also moves into a PARTNER_UI_* method state, announced by onPluginEvent
An element that can never render: a technically disabled method, a partner module that was not installed, a secondary passed to paymentElement(for:)The element is built with no UI, and reports the error as .failed(…) on the first submit()

Handle a failed outcome​

func onOutcome(_ outcome: PaymentOutcome) {
guard case let .failed(error) = outcome else { return }
switch error {
case let checkoutError as PurseCheckoutError:
switch checkoutError {
case .sessionExpired:
newSession()
case .submitBlocked:
break // already submitting, or spent: confirm on your backend
case let .validateFailed(_, underlying)
where (underlying as? PaymentAPIError)?.outcomeUnknown == true:
showStillProcessing() // the payment may exist: confirm on your backend
default:
showError(error.code)
}
case let apiError as PaymentAPIError:
if apiError.isSessionTerminal { newSession() }
else if apiError.isRetryable { offerRetry() }
else { showError(apiError.code) }
case is PartnerUIError:
showError(error.code) // the partner's step failed; pick another method
default:
showError(error.code)
}
}

Catch by family​

When you call the checkout directly:

do {
_ = try await checkout.submitPayment()
} catch let error as PurseCheckoutError { // checkout logic
switch error.code {
case .paymentNotFulfilledOnSubmit: highlightMissingAmount()
case .elementNotFulfilled: focusInvalidForm()
case .expiredSession: newSession()
default: showError(error.code)
}
} catch let error as PaymentAPIError { // transport / HTTP
if error.isSessionTerminal { newSession() }
else if error.isRetryable { offerRetry() }
else { showError(error.code) }
}

PaymentAPIError carries the HTTP status (nil for a transport failure), the API's own errorCode (SESSION_EXPIRED, …), the request path and requestSent. isSessionTerminal says the session is spent for good; isRetryable says the same call may succeed (a 5xx, or a transport failure that did leave the device, never a 4xx and never a request that was never sent).

A /validate that fails in transport does not arrive as a bare PaymentAPIError: the SDK wraps it in PurseCheckoutError.validateFailed, keeping the transport error as underlying. Inspect that chain rather than the top-level type, as below.

Unknown outcome​

outcomeUnknown is the one flag that must not be got wrong. It lives on PaymentAPIError and is true when the /validate request left the device and no answer came back: status == nil && requestSent. The payment may exist server-side. Never show it to a shopper as a failed payment: confirm the status on your backend first.

An error that provably sent no byte (an unusable URL, a body that could not be encoded) has requestSent == false, is not reported as unknown and is never retried. That extra condition is iOS's, and it exists because acting on outcomeUnknown spends the session.

PaymentValidator wraps the transport error in PurseCheckoutError.validateFailed, so a cast at the top level matches nothing. Walk the underlying chain:

func outcomeUnknown(_ error: any PurseError) -> Bool {
var current: (any Error)? = error
while let error = current {
if (error as? PaymentAPIError)?.outcomeUnknown == true { return true }
current = (error as? any PurseError)?.underlying
}
return false
}

if outcomeUnknown(error) {
showStillProcessing() // never show a failure: confirm on your backend
}

When the outcome is unknown, confirm the payment status on your backend, from webhooks or Get a client session, before you tell the shopper anything final.

A return from a redirect lands on PaymentOutcome.returnedFromRedirect(url:), never on .canceled, and is not an outcome either: the final redirect is built server-side and can land on a URL the app cannot read. The same confirmation applies.

A declined payment is not an error​

PaymentOutcome.completed with a status other than .success is the API's verdict on a payment that went through the whole flow: the issuer declined, the partner refused. Show the shopper a decline message and let them pick another method. Nothing is thrown and no .failed is reported. The session is spent (sessionState is .submitted): a retry needs a new client session.

A method is listed but cannot be used​

The method's disabled state says why and what to do, and moves as the split moves. See Method states for the codes, the five behaviours and the reaction to each.

A partner module that was never installed​

Swift has no ServiceLoader: linking PurseCheckoutApplePay, PurseCheckoutPayPal or PurseCheckoutKlarna is not enough, each one is installed explicitly at start-up.

PluginRegistry.shared.install(ApplePayModule(configuration: applePayConfiguration))
PluginRegistry.shared.install(PayPalModule())
PluginRegistry.shared.install(KlarnaModule())

If the session offers a method whose module is not installed, the item's element has no UI and submit() reports a .failed(PurseCheckoutError.noPluginRegistered) (METHOD_NOT_IMPLEMENTED) whose message reads:

no plugin registered for paypal-wallet — install the partner product that provides it

Install the module, or pass the method in PurseHeadlessCheckout(widgetData:disabledMethods:): Payment methods.

Two installed modules still refuse to render without what only your app can give them, and say so the same way: Apple Pay without an ApplePayConfiguration ("Apple Pay needs a merchant id: install ApplePayModule(configuration:)") and Klarna without an app return URL ("Klarna needs an app return URL: pass appReturnURL to paymentElement(for:…)"), both as a PartnerUIError.

Do not retry /init yourself

Transient /init failures are retried by the SDK's InitGuard, which also coalesces concurrent calls. Add no retry loop of your own; react to the resulting state instead.