Error handling
Every SDK exception extends PurseException 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.
Where errors surface
| Path | Surface |
|---|---|
element.submit() | PaymentOutcome.Failed(error) on the element's listener. Most integrations only need this |
checkout.submitPayment() and every other suspend function | The exception is thrown |
setPrimary, applySecondary, register, … (synchronous) | The exception is thrown |
| A partner SDK's own failure | PartnerUiException (PARTNER_UI_ERROR), the partner's exception as its cause |
| A rendering that cannot happen (technically disabled method) | The element has no UI; submit() reports Failed(MethodDisabled) |
Handle a failed outcome
override fun onOutcome(outcome: PaymentOutcome) {
if (outcome !is PaymentOutcome.Failed) return
when (val error = outcome.error) {
is PurseCheckoutException.SessionExpired -> newSession()
is PurseCheckoutException.SubmitBlocked -> Unit // already submitting, or spent
is PurseCheckoutException.ValidateFailed ->
if (error.outcomeUnknown) showProcessing() else showError(error.code) // unknown: ask your backend
is PaymentApiException ->
if (error.isSessionTerminal) newSession() else if (error.isRetryable) offerRetry() else showError(error.code)
is PartnerUiException -> showError(error.code) // the partner's step failed; pick another method
else -> showError(error.code)
}
}
Catch by family
When you call the checkout directly:
try {
checkout.submitPayment() // suspend, runs its network call on Dispatchers.IO
} catch (e: PurseCheckoutException) { // checkout logic
when (e.code) {
PurseErrorCode.PAYMENT_NOT_FULFILLED_ON_SUBMIT -> highlightMissingAmount()
PurseErrorCode.ELEMENT_NOT_FULFILLED -> focusInvalidForm()
PurseErrorCode.EXPIRED_SESSION -> newSession()
else -> showError(e.code)
}
} catch (e: PaymentApiException) { // transport / HTTP
if (e.isSessionTerminal) newSession() else if (e.isRetryable) offerRetry() else showError(e.code)
}
PaymentApiException carries the HTTP status (null for a transport failure), the API's own errorCode (SESSION_EXPIRED, …) and the request path. isSessionTerminal says the session is spent for good; isRetryable says the same call may succeed (a 5xx or a transport failure, never a 4xx).
Unknown outcome
A ValidateFailed whose outcomeUnknown is true means the /validate request left the device and no answer came back. The payment may exist server-side: do not tell the shopper it failed. Show a processing state and get the final status from your backend, through webhooks or Get a client session.
A return from a redirect after Android killed the app lands on PaymentOutcome.ReturnedFromRedirect, never on Canceled: the same backend check 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 ERROR and a new submit() throws SubmitBlocked. 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 missing partner artifact
If the session offers Google Pay, PayPal or Klarna and the matching artifact is not on the classpath, the SDK logs a warning at init naming the dependency, the method's element has no UI, and submit() reports a Failed(NoPluginRegistered) (METHOD_NOT_IMPLEMENTED) whose message reads:
no plugin registered for paypal/wallet — add the `eu.purse:headless-checkout-paypal` dependency to offer this method
Add the artifact, or pass the method in init(disabledMethods = …): Payment methods.
Transient /init failures are retried by the SDK. Add no retry loop of your own; react to the resulting disabled state instead.