Skip to main content

Events and tokenization

The SDK emits events as the user interacts with the card form. Events contain field metadata — never raw card values. When the form is complete, call submit() to tokenize and receive a vault_form_token.

Add a listener

Register a SecureFieldsListener with addListener(). It returns an unsubscribe function. Override only the methods you need — all have default no-op implementations.

val unsubscribe = secureFields.addListener(object : SecureFieldsListener {
override fun onFormValid(payload: FormValidPayload) {
submitButton.isEnabled = !payload.hasErrors
}
})

// Remove the listener when no longer needed:
unsubscribe()

Wait for the SDK to be ready

onReady() fires once after all field views are attached and ready for input. Use it to hide a loading indicator.

secureFields.addListener(object : SecureFieldsListener {
override fun onReady() {
loadingIndicator.isVisible = false
cardForm.isVisible = true
}
})

Track form validity

secureFields.addListener(object : SecureFieldsListener {
override fun onFormValid(payload: FormValidPayload) {
// payload.hasErrors is false when every rendered field passes validation
submitButton.isEnabled = !payload.hasErrors

// Per-field state — validity and character count only, not card values
val cardState = payload.fields[VaultFieldName.CardNumber]
val cvvState = payload.fields[VaultFieldName.Cvv]
}
})

Track individual field changes

onChange(), onFocus(), and onBlur() all receive a FieldStatePayload:

secureFields.addListener(object : SecureFieldsListener {
override fun onChange(payload: FieldStatePayload) {
// payload.fieldName — "cardNumber", "expDate", "cvv", or "holderName"
// payload.valid — true when the field passes format validation
// payload.length — character count (not the card value)
// payload.touched — true if the user has focused the field at least once
// payload.error — "format" when invalid, null otherwise
}
})

Tokenization failures are not delivered through a listener callback — they come back as a SubmitResult.Error from submit(). See Handle the tokenization result.

Brand detection

The SDK performs a BIN lookup after the user types enough digits to identify the card network. It emits onBrandDetected() when a match is found and onBrandNotDetected() when the entered digits do not match any configured brand.

secureFields.addListener(object : SecureFieldsListener {
override fun onBrandDetected(payload: BrandDetectedPayload) {
// payload.brands — e.g. [Brand.VISA] or [Brand.VISA, Brand.CB] for co-branded cards
showBrandIcons(payload.brands)
}

override fun onBrandNotDetected() {
hideBrandIcons()
}
})

Co-branded card picker

Set brandSelector = true in your SecureFieldsConfig to let the cardholder pick a network for co-branded cards (e.g. CB/VISA) instead of the SDK defaulting to one.

If you render the form with VaultForm, the SDK shows a native picker automatically — no extra UI work needed. It only appears when a card matches more than one of your configured brands.

If you build a custom field layout — either with VaultField composables or with the View system (VaultFieldView) — the SDK has no layout of its own to inject the picker into. Build your own picker from onBrandDetected() and call setBrandSelection(), the same as the View system tab shows.

onBrandUserSelection() fires once the SDK resolves a choice — whether from the automatic Compose picker or your own setBrandSelection() call:

secureFields.addListener(object : SecureFieldsListener {
override fun onBrandUserSelection(payload: BrandUserSelectionPayload) {
// payload.selected — the brand the SDK resolved (matches your selection when valid)
// payload.matchedBrands — all matching candidates for the card
highlightSelectedBrand(payload.selected)
}
})
note

setBrandSelection() only accepts a brand that's actually among the matched candidates (or, before any card number is entered, among your configured brands). Passing anything else resets to the SDK's own default resolution rather than being applied — it never throws.

The resolved brand is sent to the gateway as the selected network automatically at submit() time — it overrides any selectedNetwork you pass in SubmitOptions. When brandSelector = false, pass the network explicitly to submit() instead — see Pass submit options.

Get card info

Call getCardInfo() at any time after the form is ready. It is a suspend function — call it from a coroutine. Returns non-sensitive metadata about the entered card, or null if the card number field does not yet have enough digits for a BIN lookup.

val info = secureFields.getCardInfo()
if (info != null) {
println("BIN: ${info.bin}") // first 8 digits
println("Brands: ${info.detectedBrands}")
println("Last four: ${info.lastFourDigits}")
}

getCardInfo() never returns the full PAN.

Submit the form

submit() triggers validation, then tokenizes the card data against the Purse gateway. It is a suspend function — call it from a coroutine.

@Composable
fun CheckoutScreen() {
val scope = rememberCoroutineScope()
val secureFields = rememberSecureFields(config = ...)
var formValid by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }

LaunchedEffect(secureFields) {
secureFields.addListener(object : SecureFieldsListener {
override fun onFormValid(payload: FormValidPayload) {
formValid = !payload.hasErrors
}
})
}

Button(
enabled = formValid && !isLoading,
onClick = {
scope.launch {
isLoading = true
when (val result = secureFields.submit()) {
is SubmitResult.Success -> onTokenReceived(result.vaultFormToken)
is SubmitResult.Error -> showError(result.message)
}
isLoading = false
}
}
) { Text("Pay") }
}

Pass submit options

Provide a SubmitOptions when you need to specify the card network or control token persistence:

val result = secureFields.submit(
SubmitOptions(
selectedNetwork = "VISA", // required when brandSelector = false and multiple brands detected
saveToken = false,
)
)

Handle the tokenization result

submit() returns a SubmitResult sealed class. Handle both variants exhaustively:

when (val result = secureFields.submit()) {

is SubmitResult.Success -> {
val token = result.vaultFormToken // always present on success
val card = result.card // CardInfo — present when the gateway returns it
val birth = result.birthDate // ISO 8601 date — present for Oney flows only

// Pass the token to your backend to initiate payment
sendToBackend(token)
}

is SubmitResult.Error -> {
println("[${result.code}] ${result.message}")
}
}

Result variants

On SubmitResult.Success, vaultFormToken is always present (non-null); card and birthDate are optional.

ScenariovaultFormTokencardbirthDate
Standard tokenizationPresentOptional
Oney flowPresentOptionalPresent
Failure (SubmitResult.Error)n/a — no token; check code / messagen/an/a

Listener event reference

EventPayloadWhen it fires
onReady()All field views are attached and ready
onChange(payload)FieldStatePayloadUser types, pastes, or clears a field
onFocus(payload)FieldStatePayloadA field gains focus
onBlur(payload)FieldStatePayloadA field loses focus
onFormValid(payload)FormValidPayloadAny field changes validity; fires on each keystroke
onBrandDetected(payload)BrandDetectedPayloadBIN lookup returns a match
onBrandNotDetected()BIN lookup returns no match
onBrandUserSelection(payload)BrandUserSelectionPayloadUser picks a brand from the co-branded picker
onTokenizeStart()Tokenization network request starts
onTokenizeResult(result)SubmitResultTokenization completes (success or error)
onSecurityWarning(payload)SecurityWarningPayloadA device/environment security expectation isn't met at render() time — see Security and compliance

Error codes

A failed submit() returns SubmitResult.Error(message, code), where code is one of:

CodeCause
NOT_RENDEREDsubmit() was called before render() attached the fields
COLLECTION_FAILEDThe SDK could not collect card data from the fields
HTTP status (e.g. 400, 422)The gateway rejected the request; message carries the detail
nullThe gateway returned an error without a status code — check message
NETWORK_ERRORThe tokenization request could not reach the gateway
Thrown, not returned

Two conditions surface as exceptions rather than a SubmitResult.Error, so wrap submit() in a try/catch if you need to handle them:

  • submit() enforces a 30-second deadline and throws kotlinx.coroutines.TimeoutCancellationException if the gateway does not respond in time.
  • Calling submit() (or any other method) after destroy() throws SecureFieldsException.InstanceDestroyed.