Recipe
- Web
- Android
- iOS
checkout.addEventListener('primer:ready', (event) => {
const primer = event.detail;
primer.onPaymentFailure = ({ error }) => {
console.error('Payment failed:', {
code: error.code,
message: error.message,
diagnosticsId: error.diagnosticsId, // Share with Primer support
});
};
});
val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Failure -> {
val error = s.primerError
Log.e("Checkout", "Payment failed: ${error.description}")
Log.e("Checkout", "Error code: ${error.errorCode}")
Log.e("Checkout", "Diagnostics ID: ${error.diagnosticsId}")
Log.e("Checkout", "Recovery: ${error.recoverySuggestion}")
}
else -> Unit
}
}
PrimerCheckoutSheet(checkout = checkout)
PrimerCheckout(
clientToken: clientToken,
onCompletion: { state in
if case let .failure(error, checkoutData) = state {
print("[Primer] Error ID: \(error.errorId)")
print("[Primer] Description: \(error.errorDescription ?? "N/A")")
print("[Primer] Diagnostics: \(error.diagnosticsId)")
print("[Primer] Recovery: \(error.recoverySuggestion ?? "N/A")")
print("[Primer] Payment ID: \(checkoutData?.payment?.id ?? "none")")
print("[Primer] Order ID: \(checkoutData?.payment?.orderId ?? "none")")
}
}
)
How it works
- Web
- Android
- iOS
- Listen for the
primer:readyevent to access the Primer SDK instance - Set the
onPaymentFailurecallback - Log the error details including
diagnosticsIdwhich helps Primer support investigate issues
- Observe
checkout.statewithcollectAsStateWithLifecycle()and react in aLaunchedEffect - Handle
PrimerCheckoutState.Failureto access thePrimerError - Log the error details including
diagnosticsIdwhich helps Primer support investigate issues
PrimerError includes these properties:| Property | Type | Description |
|---|---|---|
errorId | String | Unique error identifier |
description | String | Human-readable error message |
errorCode | String? | Error code (e.g., "card_declined") |
diagnosticsId | String | Reference ID for Primer support |
recoverySuggestion | String? | Suggested recovery action for the user |
- Receive
PrimerCheckoutStatethrough theonCompletionclosure onPrimerCheckoutor the.primerCheckoutSession(_:theme:onCompletion:)modifier, and match.failure - Access
PrimerErrorproperties for debugging details - Log the
diagnosticsIdwhich helps Primer support investigate issues
PrimerError includes these properties:| Property | Type | Description |
|---|---|---|
errorId | String | Stable identifier for the error category |
errorDescription | String? | Human-readable error message for logging |
diagnosticsId | String | Reference ID for Primer support |
recoverySuggestion | String? | Developer-facing hint on how to fix the cause. nil for most payment failures, such as payment-failed |
.failure also carries checkoutData. When the payment was created before it failed (for example a decline), checkoutData?.payment holds the payment ID and order ID. Log them so you can look up the payment on your backend. checkoutData is nil when the failure happened before a payment existed (initialization, tokenization, connectivity)..failure arrives once per failed payment attempt. With the SDK error screen on (the default), the checkout stays open for a retry. Don’t treat .failure as the end of the session. .success or .dismissed arrives once and ends the session.If loading fails, PrimerCheckout delivers .failure. With the SDK error screen on (the default), the checkout stays open for a retry. With isErrorScreenEnabled: false, the checkout shows nothing and you build your own retry UI. The .primerCheckoutSession modifier delivers a load failure once and ends the session.The
diagnosticsId is a unique identifier for the error. When contacting Primer support, always include this ID to help them quickly locate and diagnose the issue.Variations
Send errors to logging service
- Web
- Android
- iOS
primer.onPaymentFailure = ({ error, paymentMethodType }) => {
// Send to your logging service (Sentry, LogRocket, etc.)
Sentry.captureException(new Error(error.message), {
extra: {
code: error.code,
diagnosticsId: error.diagnosticsId,
paymentMethod: paymentMethodType,
},
});
};
is PrimerCheckoutState.Failure -> {
val error = s.primerError
Sentry.captureException(Exception(error.description)) { scope ->
scope.setExtra("error_code", error.errorCode.orEmpty())
scope.setExtra("diagnostics_id", error.diagnosticsId)
}
}
if case let .failure(error, _) = state {
ErrorLogger.log(
errorId: error.errorId,
description: error.errorDescription ?? "Unknown error",
diagnosticsId: error.diagnosticsId
)
}
Custom error display
- Web
- Android
- iOS
const primerEvents = [
'primer:ready',
'primer:state-change',
'primer:methods-update',
'primer:payment-start',
'primer:payment-success',
'primer:payment-failure',
'primer:card-success',
'primer:card-error',
'primer:bin-data-available',
'primer:vault-methods-update',
];
primerEvents.forEach((eventName) => {
document.addEventListener(eventName, (event) => {
console.log(`[${eventName}]`, event.detail);
});
});
Override the error slot in
PrimerCheckoutSheet to display a custom error screen. Observe checkout.state separately to log diagnostics:val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Failure -> {
Log.e("Checkout", "Diagnostics: ${s.primerError.diagnosticsId}")
}
else -> Unit
}
}
PrimerCheckoutSheet(
checkout = checkout,
error = { primerError, _ ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
Text(
text = "Something went wrong",
style = MaterialTheme.typography.titleLarge,
)
Spacer(Modifier.height(8.dp))
Text(
text = primerError.description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (primerError.recoverySuggestion != null) {
Spacer(Modifier.height(4.dp))
Text(
text = primerError.recoverySuggestion!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { checkout.refresh() },
modifier = Modifier.fillMaxWidth(),
) {
Text("Try again")
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { checkout.dismiss() },
modifier = Modifier.fillMaxWidth(),
) {
Text("Use a different method")
}
}
},
onDismiss = { },
)
Compose the checkout with
PrimerCheckoutSession. Turn off the SDK error screen with PrimerUIOptions(isErrorScreenEnabled: false). Then use each .failure from the .primerCheckoutSession modifier’s onCompletion to drive your own error screen. If you leave the SDK error screen on (the default), it opens in a sheet over your view on every failed payment. Your error view appears only after the shopper dismisses that sheet.struct CheckoutView: View {
@StateObject private var session: PrimerCheckoutSession
@State private var paymentError: PrimerError?
init(clientToken: String) {
let settings = PrimerSettings(uiOptions: PrimerUIOptions(isErrorScreenEnabled: false))
_session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken, settings: settings))
}
var body: some View {
ScrollView {
if paymentError != nil {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
.foregroundColor(.red)
Text("Something went wrong")
.font(.title2)
Text("Please try a different payment method.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
Button("Try again") {
paymentError = nil
Task {
if session.phase == .ready {
await session.refresh()
} else {
session.cancel()
await session.start()
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
} else {
PrimerPaymentMethods()
PrimerCardForm()
}
}
.primerCheckoutSession(session) { state in
if case let .failure(error, checkoutData) = state {
print("[Primer] Error: \(error.errorDescription ?? "N/A")")
print("[Primer] Diagnostics: \(error.diagnosticsId)")
print("[Primer] Payment ID: \(checkoutData?.payment?.id ?? "none")")
paymentError = error
}
}
}
}
refresh() only reloads a session that has finished loading. If loading failed, “Try again” calls cancel() and then start() to rebuild the session.Inline error handling
- Web
- Android
- iOS
checkout.addEventListener('primer:methods-update', (event) => {
const methods = event.detail.toArray();
console.table(methods.map((m) => ({ type: m.type, id: m.id })));
});
For
PrimerCheckoutHost, observe checkout.state and display your own UI:@Composable
fun InlineCheckoutWithErrors(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
val state by checkout.state.collectAsStateWithLifecycle()
var paymentError by remember { mutableStateOf<PrimerError?>(null) }
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Failure -> {
paymentError = s.primerError
}
is PrimerCheckoutState.Success -> {
paymentError = null
}
else -> Unit
}
}
when (state) {
is PrimerCheckoutState.Loading -> {
CircularProgressIndicator()
}
is PrimerCheckoutState.Ready -> {
PrimerCheckoutHost(checkout = checkout) {
val cardFormController = rememberCardFormController(checkout)
Column(Modifier.padding(16.dp)) {
PrimerCardForm(controller = cardFormController)
paymentError?.let { error ->
Spacer(Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = error.description,
style = MaterialTheme.typography.bodyMedium,
)
if (error.recoverySuggestion != null) {
Text(
text = error.recoverySuggestion!!,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
}
}
}
}
Read per-field validation errors from
PrimerCardFormSession.state.fieldErrors inside a PrimerCardForm slot, and handle each failed payment attempt in onCompletion:struct InlineCheckoutWithErrors: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken))
}
var body: some View {
ScrollView {
PrimerCardForm(
cardDetails: { cardSession in
CardDetails(session: cardSession)
}
)
.padding()
}
.primerCheckoutSession(session) { state in
if case let .failure(error, _) = state {
print("[Primer] Payment error: \(error.errorDescription ?? "N/A")")
}
}
}
}
struct CardDetails: View {
@ObservedObject var session: PrimerCardFormSession
var body: some View {
VStack(alignment: .leading, spacing: 8) {
CardFormDefaults.cardDetails(session)
ForEach(session.state.fieldErrors) { fieldError in
Text("\(fieldError.fieldType): \(fieldError.message)")
.font(.caption)
.foregroundColor(.red)
}
}
}
}
See also
Track payment in analytics
Send payment events to your analytics platform
Error handling
Handle payment failures and display error messages