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.
- Jetpack Compose
- View system
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.
The SDK has no layout of its own to inject a picker into, so build your own UI from onBrandDetected() and report the choice with setBrandSelection():
secureFields.addListener(object : SecureFieldsListener {
override fun onBrandDetected(payload: BrandDetectedPayload) {
// More than one brand means the card is genuinely co-branded — offer a choice.
if (payload.brands.size > 1) {
showBrandPicker(payload.brands) { chosen ->
secureFields.setBrandSelection(chosen)
}
} else {
hideBrandPicker()
}
}
})
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)
}
})
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.
- Jetpack Compose
- View system
@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") }
}
binding.payButton.setOnClickListener {
lifecycleScope.launch {
binding.payButton.isEnabled = false
when (val result = secureFields.submit()) {
is SubmitResult.Success -> navigateToConfirmation(result.vaultFormToken)
is SubmitResult.Error -> {
showSnackbar(result.message)
binding.payButton.isEnabled = true
}
}
}
}
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.
| Scenario | vaultFormToken | card | birthDate |
|---|---|---|---|
| Standard tokenization | Present | Optional | — |
| Oney flow | Present | Optional | Present |
Failure (SubmitResult.Error) | n/a — no token; check code / message | n/a | n/a |
Listener event reference
| Event | Payload | When it fires |
|---|---|---|
onReady() | — | All field views are attached and ready |
onChange(payload) | FieldStatePayload | User types, pastes, or clears a field |
onFocus(payload) | FieldStatePayload | A field gains focus |
onBlur(payload) | FieldStatePayload | A field loses focus |
onFormValid(payload) | FormValidPayload | Any field changes validity; fires on each keystroke |
onBrandDetected(payload) | BrandDetectedPayload | BIN lookup returns a match |
onBrandNotDetected() | — | BIN lookup returns no match |
onBrandUserSelection(payload) | BrandUserSelectionPayload | User picks a brand from the co-branded picker |
onTokenizeStart() | — | Tokenization network request starts |
onTokenizeResult(result) | SubmitResult | Tokenization completes (success or error) |
onSecurityWarning(payload) | SecurityWarningPayload | A 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:
| Code | Cause |
|---|---|
NOT_RENDERED | submit() was called before render() attached the fields |
COLLECTION_FAILED | The SDK could not collect card data from the fields |
HTTP status (e.g. 400, 422) | The gateway rejected the request; message carries the detail |
null | The gateway returned an error without a status code — check message |
NETWORK_ERROR | The tokenization request could not reach the gateway |
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 throwskotlinx.coroutines.TimeoutCancellationExceptionif the gateway does not respond in time.- Calling
submit()(or any other method) afterdestroy()throwsSecureFieldsException.InstanceDestroyed.