Recipe: Show saved methods count
- Web
- Android
- iOS
document.addEventListener('primer:vault-methods-update', (event) => {
const { vaultedPayments } = event.detail;
const count = vaultedPayments.size();
document.getElementById('saved-methods-badge').textContent =
count > 0 ? `${count} saved` : '';
});
Use This renders a list of saved payment methods with card brand icons, masked card numbers, and expiry dates. The customer can select a saved method to pay with it.
PrimerVaultedPaymentMethods to render saved methods:val checkout = rememberPrimerCheckoutController(clientToken)
val vaultedController = rememberVaultedPaymentMethodsController(checkout)
PrimerVaultedPaymentMethods(controller = vaultedController)
Read the count inside the
header slot of PrimerVaultedPaymentMethods, which hands you the PrimerSelectionSession. Custom slot content must be wrapped in AnyView:PrimerVaultedPaymentMethods(
header: { session in
let count = session.vaultedPaymentMethods.count
return AnyView(Text(count > 0 ? "\(count) saved" : ""))
}
)
Recipe: List saved cards
- Web
- Android
- iOS
document.addEventListener('primer:vault-methods-update', (event) => {
const methods = event.detail.vaultedPayments.toArray();
const container = document.getElementById('saved-cards');
container.innerHTML = methods
.filter((m) => m.paymentMethodType === 'PAYMENT_CARD')
.map((m) => `
<div class="saved-card">
${m.paymentInstrumentData.network} •••• ${m.paymentInstrumentData.last4Digits}
</div>
`)
.join('');
});
For full control over how saved methods are displayed, observe the controller’s state directly:
@Composable
fun CustomVaultedMethods(
checkout: PrimerCheckoutController,
) {
val vaultedController = rememberVaultedPaymentMethodsController(checkout)
val vaultedState by vaultedController.state.collectAsStateWithLifecycle()
Column {
vaultedState.paymentMethods.forEach { method ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
onClick = { vaultedController.select(method) },
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = method.paymentMethodType,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = method.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (vaultedState.selectedMethod == method) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
Override the
item slot of PrimerVaultedPaymentMethods to render the saved method with custom UI. The view is a one-row shortcut, so the slot is called once — for the selected method, with isSelected: true. It receives the method, that flag, and an onSelect callback; custom content must be wrapped in AnyView:PrimerVaultedPaymentMethods(
item: { method, isSelected, onSelect in
AnyView(
Button(action: onSelect) {
HStack {
VStack(alignment: .leading) {
Text(method.paymentMethodType)
.font(.body)
if method.paymentMethodType == "PAYMENT_CARD" {
Text("Saved card")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
}
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
}
.buttonStyle(.plain)
)
}
)
How it works
- Web
- Android
- iOS
- Listen for the
primer:vault-methods-updateDOM event - Access
vaultedPaymentsfrom the event detail - Use
.size()to get the count or.toArray()to iterate over methods - Each method contains
paymentMethodTypeandpaymentInstrumentDatawith card details
- Create a vaulted payment methods controller using
rememberVaultedPaymentMethodsController() - Use
PrimerVaultedPaymentMethodsfor the default rendering, or observe the controller’s state for a custom UI - To save new payment methods, call
cardFormController.setVaultOnSuccess(true)before payment
Vaulting requires a
customerId in your client session. Pass it when creating the session on your server.- Place
PrimerVaultedPaymentMethodsinside a hierarchy that applies the.primerCheckoutSession(_:theme:onCompletion:)modifier. The modifier injects aPrimerSelectionSessioninto the environment, whichPrimerVaultedPaymentMethodsresolves internally - The
headerslot hands your code thatPrimerSelectionSession. Readsession.vaultedPaymentMethodsfor the full list of saved methods, orsession.stateforselectedVaultedPaymentMethodandisVaultPaymentLoading. Theitemslot receives the method, anisSelectedflag and anonSelectcallback. ThesubmitButtonslot receivesisLoading,isEnabledand anonSubmitcallback. When a card needs CVV recapture, the SDK raises the CVV recapture screen. Outside the slots, reach the same session ascheckoutSession.selection PrimerVaultedPaymentMethods()renders the selected method as a one-row shortcut and hides itself when nothing is saved; its default header links to the SDK’s saved-methods screen for switching or deleting. Override theitemslot to restyle that row, or build your own layout overvaultedPaymentMethodsif you want every method visible inline- The view’s submit button pays with the selected method
- To save new payment methods, call
try await checkoutSession.cardForm?.setVaultOnSuccess(true)before payment
Vaulting requires a
customerId in your client session. Pass it when creating the session on your server.Variations
Save and display methods
- Web
- Android
- iOS
document.addEventListener('primer:vault-methods-update', (event) => {
const methods = event.detail.vaultedPayments.toArray();
const container = document.getElementById('saved-methods');
container.innerHTML = methods
.map((method) => {
if (method.paymentMethodType === 'PAYMENT_CARD') {
const { network, last4Digits } = method.paymentInstrumentData;
return `
<div class="saved-method">
<img src="/icons/${network.toLowerCase()}.svg" alt="${network}">
<span>•••• ${last4Digits}</span>
</div>
`;
}
return `
<div class="saved-method">
<img src="/icons/${method.paymentMethodType.toLowerCase()}.svg" alt="${method.paymentMethodType}">
<span>${method.paymentMethodType}</span>
</div>
`;
})
.join('');
});
To save a new payment method after a successful payment, call
setVaultOnSuccess() on the card form controller:val cardFormController = rememberCardFormController(checkout)
LaunchedEffect(cardFormController) {
cardFormController.setVaultOnSuccess(true)
}
selectVaulted(_:) is the pay verb, so call it from your pay button rather than from a row tap. Keep which row looks selected in your own view state:// Pays with the saved method. Returns immediately; the payment runs on.
session.selectVaulted(method)
.primerCheckoutSession(_:theme:onCompletion:).Before a saved-method payment starts, the SDK calls the onBeforePaymentCreate handler you set on PrimerCheckoutSession. In that handler, you can abort the payment or supply an idempotency key. An abort arrives as a payment failure.PrimerVaultedPaymentMethods renders one saved method and a submit button that calls selectVaulted(_:) for you.To save a new card after a successful payment, call setVaultOnSuccess(_:) on the card form session. It updates the client session, which needs a customerId. Call it with false to stop saving, for example from a customer-facing “Save card” toggle:// `checkoutSession` is the PrimerCheckoutSession you hold as @StateObject
PrimerCardForm()
.task(id: checkoutSession.phase) {
// `cardForm` is nil until the session is ready, so this runs again when `phase` changes
try? await checkoutSession.cardForm?.setVaultOnSuccess(true)
}
Inline saved methods with card form
- Web
- Android
- iOS
document.addEventListener('primer:vault-methods-update', (event) => {
const methods = event.detail.vaultedPayments.toArray();
methods
.filter((m) => m.paymentMethodType === 'PAYMENT_CARD')
.forEach((method) => {
const { network, last4Digits, expirationMonth, expirationYear } =
method.paymentInstrumentData;
console.log(
`${network} •••• ${last4Digits} - Expires ${expirationMonth}/${expirationYear}`,
);
});
});
This example shows saved methods above the card form, letting the customer choose between a saved method or entering new card details:
@Composable
fun InlineCheckoutWithVault(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (state) {
is PrimerCheckoutState.Success -> { }
is PrimerCheckoutState.Failure -> { }
else -> Unit
}
}
when (state) {
is PrimerCheckoutState.Loading -> {
CircularProgressIndicator()
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(
checkout = checkout,
) {
val vaultedController = rememberVaultedPaymentMethodsController(checkout)
val cardFormController = rememberCardFormController(checkout)
LaunchedEffect(cardFormController) {
cardFormController.setVaultOnSuccess(true)
}
Column(Modifier.padding(16.dp)) {
Text(
text = "Saved payment methods",
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
PrimerVaultedPaymentMethods(controller = vaultedController)
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
Text(
text = "Or pay with a new card",
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
PrimerCardForm(controller = cardFormController)
}
}
}
}
}
Compose
PrimerVaultedPaymentMethods and PrimerCardForm under the .primerCheckoutSession(_:theme:onCompletion:) modifier, letting the customer choose between a saved method or entering new card details:struct InlineCheckoutWithVault: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
)
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
Text("Saved payment methods")
.font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
PrimerVaultedPaymentMethods()
Divider()
Text("Or pay with a new card")
.font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
PrimerCardForm()
}
.padding()
}
.primerCheckoutSession(session) { state in
switch state {
case .success(let data):
print("Payment complete: \(data)")
case let .failure(error, _):
print("Payment failed: \(error)")
default:
break
}
}
}
}
Conditional UI based on saved methods
- Web
- Android
- iOS
document.addEventListener('primer:vault-methods-update', (event) => {
const { vaultedPayments } = event.detail;
const hasSavedMethods = vaultedPayments.size() > 0;
document.getElementById('saved-methods-section').style.display = hasSavedMethods
? 'block'
: 'none';
document.getElementById('new-payment-section').querySelector('h3').textContent =
hasSavedMethods ? 'Or pay with a new method' : 'Payment method';
});
The bottom sheet shows vaulted methods automatically when they are available. No extra configuration is needed beyond providing a
customerId in the client session:@Composable
fun CheckoutWithSavedMethods(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Success -> { }
is PrimerCheckoutState.Failure -> {
Log.e("Checkout", "Failed: ${s.primerError.diagnosticsId}")
}
else -> Unit
}
}
when (state) {
is PrimerCheckoutState.Loading -> {
CircularProgressIndicator()
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutSheet(
checkout = checkout,
onDismiss = { },
)
}
}
}
The managed For inline layouts, To customize the section’s own heading, override the
PrimerCheckout shows vaulted methods automatically when they are available. No extra configuration is needed beyond providing a customerId in the client session:PrimerCheckout(clientToken: clientToken) { state in
// Handle the terminal payment result
}
PrimerVaultedPaymentMethods hides itself entirely when the customer has no saved methods, so you do not need to branch on the empty case to keep the section from appearing.That also means you cannot render an empty-state message from inside the
header slot — when there is nothing saved, no slot is called at all. Branch in the layout that contains the view instead, reading vaultedPaymentMethods from checkoutSession.selection. Observe that PrimerSelectionSession with @ObservedObject. Saved methods load after the session is ready. A view that observes only the checkout session does not update when they load or change:// Pass in the PrimerCheckoutSession you hold as @StateObject
struct SavedMethodsSection: View {
@ObservedObject var checkoutSession: PrimerCheckoutSession
var body: some View {
if let selection = checkoutSession.selection {
SavedMethodsHeading(selection: selection)
}
PrimerVaultedPaymentMethods()
}
}
// Observes the selection session, so the heading follows saved methods as they load or are deleted
struct SavedMethodsHeading: View {
@ObservedObject var selection: PrimerSelectionSession
var body: some View {
Text(selection.vaultedPaymentMethods.isEmpty ? "Payment method" : "Or pay with a new method")
}
}
header slot — it receives the PrimerSelectionSession. Keep a “Show all” affordance: it is the route to the SDK screen where a customer deletes a saved method. Custom slot content must be wrapped in AnyView:PrimerVaultedPaymentMethods(
header: { session in
AnyView(
HStack {
Text("Pay faster")
Spacer()
Button("Manage") { session.showAll() }
}
)
}
)
See also
Vault manager component
SDK reference for saved payment methods
Events guide
Handle payment lifecycle events