Skip to main content

Key concepts

The Android SDK shares its model with the web Headless Checkout. This page explains that model in Kotlin 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 the payment succeeded, ERROR after a declined payment or a failure, EXPIRED or TERMINATED when it is over. An ERROR reached after the API answered about the payment (a decline) is spent too: a new submit throws SubmitBlocked. Only an ERROR where the API never answered stays submittable. A new cart means a new session, handed to the same checkout with setSession.

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.

ItemPaysExamples
PrimaryMethodThe remainder of the amount, whatever gift cards leftCard, Google Pay, PayPal, Klarna, a bank transfer, a BNPL provider
SecondaryMethodA slice of the amount, several can stackGift cards, loyalty cards, vouchers
PaymentToken (on paymentTokens)The remainder, like a primaryA saved card, a saved PayPal account
SavedSecondaryToken (on savedSecondaryTokens)A slice, like a secondaryA gift card kept from an earlier session

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.

  • setPrimary(method) or setPrimaryToken(token) names the primary. Google Pay and PayPal do it themselves when the shopper approves.
  • applySecondary(token, 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 the forms the SDK validates are the card form and the CVV-only form of a saved card: isPaymentFulfilled stays false while it is incomplete, and submitPayment refuses with ELEMENT_NOT_FULFILLED. 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, Google Pay's button, PayPal's button, Klarna's widget, or nothing at all for a redirection method. rememberPaymentElement(checkout, item, listener) creates it, PaymentElementUi(element) places it, and element.submit() pays.

Three flags describe it:

FlagMeaning
hasUiThere is something to place. false for redirection methods, saved cards that need no CVV, saved PayPal accounts, and technically disabled methods
selfSubmittingThe element's own UI is the Pay button. true for Google Pay and PayPal; never render a Pay button of yours next to them
readyThe step is usable: the form is valid, the widget loaded, the wallet is available. Enable your Pay button on it

Behind the element, one coordinator per method owns the partner's native SDK, its Activity launchers and the shared submit flow (/validate, redirect resolution, the return). The element is a thin shape over it; the coordinators are public for apps that need to draw a wallet button themselves.

Creating an element can cost a POST /init: Klarna needs its client token to render, redirection methods consume a session step. 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:

OutcomeMeaningWhat you do
Completed(result)/validate answered directly. status is the API's verdictisSuccess shows success; anything else is a declined payment, not an error
ReturnedFromRedirectThe shopper came back from 3-D Secure, a PSP page or PayPalNot an outcome. Show a processing state; your backend confirms the payment
CanceledThe shopper backed out of a sheet, a page, or Klarna declinedNothing changed; let them try again
Failed(error)A PurseException with a stable codeBranch 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 only reports the return. The final status comes from your backend, through webhooks or Get a client session.

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 (MIN_AMOUNT_NOT_MET, MAX_AMOUNT_EXCEEDED, PAYMENT_FULFILLED) move as the split moves; the technical ones (TOKEN_INIT_FAILED, PARTNER_UI_FATAL_ERROR) are final for the session; SUBMITTING locks every method while a payment is in flight. The complete table of codes, behaviours and expected reactions is the Method states reference.

Redirects stay in the app​

3-D Secure challenges and PSP pages open in an Activity the SDK owns: an in-app WebView restricted to https, which finishes when the page reaches the app's own return scheme. The shopper never leaves the app, and the return reaches the same payment element. PayPal's approval is the one step that switches to the PayPal app or a Custom Tab and comes back through an Activity the PayPal artifact declares. Klarna's widget can open a bank app and must return to your Activity, hence the one deep link you declare for it.

Your onRedirection hook can take a redirect over; the payment element then receives no ReturnedFromRedirect, and your backend confirms the outcome.

Errors and web parity​

Every exception extends PurseException and carries a PurseErrorCode shared with the web SDK, so a merchant on both platforms handles one vocabulary. Three codes exist only on Android 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:

TopicWebAndroidWhy
FulfilmentsetAsPrimarySource refuses an unfulfilled elementReactive isPaymentFulfilled; a method is selectable before its form is filledFits StateFlow UIs
Second submitSilently ignored, loggedThrows SubmitBlockedAn app needs a code to react to
teardown()Instance stays usableEvery later network call, host hand-out or session replacement throws CheckoutTornDownLifecycle is explicit on Android
Process deathNot applicablesaveState() / restore()Android may kill the app during a bank step
PayPal /initOn renderOn the button tapAvoids orphan PayPal orders
Session refreshResets the splitKeeps an unchanged primary selectedA cart change should not lose the shopper's choice
Same backend, same dashboard

A partner configured in your Purse dashboard is offered to both platforms by the same client session. The Supported payment methods table says which methods the Android SDK renders natively.