> ## Documentation Index
> Fetch the complete documentation index at: https://primer.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling & Failure States

> Distinguish validation errors from payment failures, and show the right message for each.

When implementing custom layouts, you need to handle payment failures and display appropriate error messages to your users. This guide covers the available options and best practices.

## Two types of errors

Before implementing error handling, it's important to understand the difference between validation errors and payment failures:

| Type | When it occurs | How it's handled |
| - | - | - |
| **Validation errors** | During form input (invalid card number, missing fields) | Handled automatically by input components; prevents form submission |
| **Payment failures** | After form submission (declined card, insufficient funds) | Requires explicit handling with error container or custom code |

There's also a third case — the SDK itself failing to start (e.g. an invalid client token) before any form is even shown. See [SDK initialization failures](#sdk-initialization-failures) below.

## Not all failures are retryable

Before prompting the user to try again, be aware of two important characteristics of payment failures:

**A client-side failure doesn't always mean the payment failed.** A failure reported by the SDK can be local to the browser or device — for example, a network drop after the payment was submitted. In that case the payment may have completed successfully even though the client saw an error. Your server — through [payment webhooks](https://primer.io/docs/get-started/payment-updates.md) or the Payments API — is the source of truth for the payment's status. Verify the status server-side before asking the user to pay again, otherwise you risk charging them twice.

**Decline reasons are intentionally generic.** When a payment is declined, the SDK reports that it failed, but not the specific reason. This is deliberate to avoid leaking details. The detailed reason is available to you server-side via [webhooks](https://primer.io/docs/api-reference/get-started/configure-webhooks.md), the Payments API, and the Primer Dashboard.

In practice: show a generic failure message, offer the user a way to retry or choose another payment method, and treat your server-side payment status — not the client-side error — as the definitive outcome.

### Deciding whether to retry (Web)

The failure payload tells you which case you're dealing with. The `error` on the [`primer:payment-failure`](https://primer.io/docs/sdk/primer-checkout-web/events-reference#primerpayment-failure) event carries a `code` identifying the failure, and an `origin` that says who should act on it:

| `origin` | What happened | Before offering a retry |
| - | - | - |
| `payment` | The payment reached Primer and failed or was declined | Safe to offer a retry — it creates a new payment attempt. Look up the decline reason server-side or in the Dashboard |
| `network` | An API request failed at the transport level (timeout, dropped connection, server error) | **The outcome is unknown — the payment may have succeeded.** Verify the payment status server-side first, or you risk a double charge |
| `primer` | The payment ended in a state the SDK can't interpret | Verify the payment status server-side; share the `diagnosticsId` with Primer support if the state is unclear |
| `user` | The shopper cancelled. Cancelled processor 3DS challenges and PayPal popups use `code: 'PAYMENT_CANCELLED'` | Safe to let the shopper try again |
| `integration` | Your integration passed something invalid | Fix the integration — the error's `suggestion` says how. Retrying won't help |

In short: on a `network` failure, ask your backend whether a payment was created — the SDK couldn't tell. On any other failure, use `payment.id` (when present) to cross-check the status and cause with your backend before deciding whether to allow a retry.

```javascript theme={"dark"}
checkout.addEventListener('primer:payment-failure', async (event) => {
  const { error, payment } = event.detail;

  if (error.origin === 'network') {
    // The SDK couldn't determine whether a payment was created.
    // Your backend knows — through Primer webhooks or the Payments API.
    const { succeeded } = await checkPaymentStatusOnYourBackend({
      orderId: myOrderId,
    });
    if (succeeded) {
      window.location.href = '/order-confirmation';
      return;
    }
    showRetryMessage();
    return;
  }

  // The payment failed. When `payment` is present, its `id` identifies the
  // failed payment — cross-check the status and decline reason with your
  // backend before deciding whether to offer a retry.
  showRetryMessage();
});
```

> [!NOTE]
>
> The failure payload's `payment` carries only `id`, `orderId`, and summary payment method data — not the payment's status. Look the status up by `id` through your backend, the Payments API, or the Dashboard.

**On SDK versions without `origin`** (before 1.6.2 at the top level; 1.6.2 to 1.9.2 expose it nested at `error.data.error.origin`), you can't detect a transport-level failure from the error itself. Use the presence of `payment` data instead: a failure **without** `payment` may be network-caused — a payment may still have been created, so verify with your backend before retrying. A failure **with** `payment` identifies the payment to cross-check by `id`.

## Default error behavior

> [tab] Web
>
> > [!NOTE]
> >
> > The `<primer-error-message-container>` specifically handles payment failures that occur after form submission, not card validation errors. Card validation is handled by the input components themselves and prevents form submission until valid.

> [tab] Android
>
> The Primer Checkout SDK handles most error scenarios automatically. Payment failures are displayed in the checkout UI, and validation errors appear inline on form fields.
>
> Form validation includes:
>
> * Card number validation (Luhn check, network detection)
> * Expiry date validation (format, future date)
> * CVV length validation (based on card network)
> * Required field validation (cardholder name, billing address)
>
> Errors appear when the user leaves a field (on blur), not while typing.
>
> When a payment fails, the SDK:
>
> 1. Emits `PrimerCheckoutState.Failure` with error details on `checkout.state`
> 2. Displays a default error screen (in `PrimerCheckoutSheet`)
> 3. Offers retry and "try other methods" options

> [tab] iOS
>
> The Primer Checkout SDK handles most error scenarios automatically. Validation errors appear inline on form fields, and payment failures are displayed in the checkout UI.
>
> Form validation includes:
>
> * Card number validation (Luhn check, network detection)
> * Expiry date validation (format, future date)
> * CVV length validation (based on card network)
> * Required field validation (cardholder name, billing address)
>
> When a payment fails, the SDK:
>
> 1. Delivers `.failure(error, checkoutData:)` to `onCompletion` once per failed attempt (UIKit: `primerCheckoutPresenterDidFailWithError(_:checkoutData:)`)
> 2. Displays a default error screen with the error's message, and keeps the checkout open on it. To hide this screen, pass `PrimerSettings(uiOptions: PrimerUIOptions(isErrorScreenEnabled: false))`. In that case, `PrimerCheckoutPresenter` closes the checkout on the failure
> 3. Offers up to two buttons on the error screen. If there is a payment attempt to re-run, **Retry** runs the failed payment method again. If another payment method or a saved payment method is available, **Choose other payment methods** returns the shopper to the payment method list. In the inline flow, this button always appears
>
> If the SDK fails to initialize (for example, because of an invalid client token), the failure is reported as `.failure` with `checkoutData` set to `nil`. `PrimerCheckout` delivers it to `onCompletion`, and `PrimerCheckoutPresenter` delivers it to `primerCheckoutPresenterDidFailWithError(_:checkoutData:)`. By default, both show an error screen with a **Retry** button, and the presenter's sheet stays open. If you set `isErrorScreenEnabled` to `false` in `PrimerSettings(uiOptions:)`, `PrimerCheckout` shows an empty view. `PrimerCheckoutPresenter` closes its sheet. In both cases, show your own fallback.
>
> With a `PrimerCheckoutSession`, the same `.failure` reaches the `onCompletion` closure of `.primerCheckoutSession(_:theme:onCompletion:)`. If initialization fails, the session ends before it reaches `.ready`. The SDK shows no screen. `session.cardForm` and `session.selection` stay `nil`, so `PrimerCardForm` and `PrimerPaymentMethods` render nothing. Show your own fallback. To try again, call `session.cancel()`, then `await session.start()`. If you call only `start()`, the session does not report `.success` or `.dismissed`.

## Built-in error display

> [tab] Web
>
> The `<primer-error-message-container>` provides a convenient way to display payment failures without writing custom code:
>
> ```html theme={"dark"}
> <primer-checkout client-token="your-token">
>   <primer-main slot="main">
>     <div slot="payments">
>       <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
>       <primer-error-message-container></primer-error-message-container>
>     </div>
>   </primer-main>
> </primer-checkout>
> ```
>
> ```html theme={"dark"}
> <primer-checkout client-token="your-token">
>   <div slot="main" id="custom-checkout">
>     <div id="payment-methods">
>       <primer-payment-method type="PAYMENT_CARD"></primer-payment-method>
>       <primer-error-message-container></primer-error-message-container>
>     </div>
>   </div>
> </primer-checkout>
> ```
>
> For optimal user experience, place the error container:
>
> 1. **Prominently** where it will be visible after a payment attempt
> 2. **Near the action** where users will naturally look for feedback after submitting payment
> 3. **In context** within the same visual area as the payment method it relates to

> [tab] Android
>
> `PrimerCheckoutSheet` displays a default error screen automatically when a payment fails. No additional code is required for basic error handling.
>
> To react to a failure in your own code, observe `checkout.state` and match on `PrimerCheckoutState.Failure`:
>
> ```kotlin theme={"dark"}
> val state by checkout.state.collectAsStateWithLifecycle()
>
> LaunchedEffect(state) {
>     val s = state
>     if (s 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}")
>     }
> }
>
> PrimerCheckoutSheet(checkout = checkout)
> ```
>
> ### Looking up a failed payment
>
> When the payment was created before it failed — a decline, for example — `Failure` also carries
> `checkoutData`, so you can look the payment up on your backend or show the merchant reference on
> your error screen:
>
> ```kotlin theme={"dark"}
> if (s is PrimerCheckoutState.Failure) {
>     val payment = s.checkoutData?.payment
>     if (payment != null) {
>         reportFailedPayment(paymentId = payment.id, orderId = payment.orderId)
>     }
> }
> ```
>
> `checkoutData` is `null` when the failure happened before a payment existed — session
> initialization, tokenization or connectivity problems — so always treat it as optional. It never
> carries the processor decline reason.

> [tab] iOS
>
> Payment failures are reported through the `onCompletion` callback. The SDK also displays a default error screen automatically:
>
> ```swift theme={"dark"}
> PrimerCheckout(clientToken: clientToken) { state in
>   if case let .failure(error, _) = state {
>     print("Error ID: \(error.errorId)")
>     print("Description: \(error.localizedDescription)")
>     print("Diagnostics ID: \(error.diagnosticsId)")
>   }
> }
> ```
>
> ### Looking up a failed payment
>
> When the payment was created before it failed — a decline, for example — `.failure` also carries
> `checkoutData`, so you can look the payment up on your backend or show the merchant reference on
> your error screen:
>
> ```swift theme={"dark"}
> if case let .failure(_, checkoutData) = state, let payment = checkoutData?.payment {
>   reportFailedPayment(paymentId: payment.id, orderId: payment.orderId)
> }
> ```
>
> `checkoutData` is `nil` when the failure happened before a payment existed — session
> initialization, tokenization or connectivity problems — so always treat it as optional. It never
> carries the processor decline reason.

### Error properties

Every error from the SDK includes diagnostic information to help identify and resolve issues.

> [tab] Web
>
> | Property | Description |
> | - | - |
> | `code` | Error code identifying the failure type (e.g. `PAYMENT_FAILED`, `PAYMENT_CANCELLED`, `REQUEST_TIMEOUT`, `SERVER_ERROR`) |
> | `message` | Human-readable error message |
> | `diagnosticsId` | Reference ID for Primer support. For failed API requests, this is the request's `X-Request-Id`, which Primer also logs server-side |
> | `origin` | Who should act on the failure: `payment`, `network`, `primer`, `user`, or `integration` — see [Deciding whether to retry](#deciding-whether-to-retry-web) |
> | `suggestion` | How to fix the error, present on `integration`-origin errors |

> [tab] Android
>
> | 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 support |
> | `recoverySuggestion` | `String?` | Suggested recovery action |

> [tab] iOS
>
> | Property | Type | Description |
> | - | - | - |
> | `errorId` | `String` | Stable, kebab-case identifier for the error category (use for programmatic branching) |
> | `diagnosticsId` | `String` | Unique per-occurrence diagnostics ID to share with Primer support |
> | `recoverySuggestion` | `String?` | Suggested recovery action, when one applies |
> | `errorDescription` | `String?` | Human-readable description for logging (also available as `localizedDescription`) |
> | `plainDescription` | `String?` | The message without the `[errorId]` and diagnostics ID wrapper. This is the text the SDK error screens show |

## Custom error handling

> [tab] Web
>
> You can implement your own payment failure handling using the SDK callbacks and events. Choose **one** of the following approaches:
>
> > [tab] Payment events (recommended)
> >
> > ```javascript theme={"dark"}
> > const checkout = document.querySelector('primer-checkout');
> >
> > checkout.addEventListener('primer:payment-success', (event) => {
> >   const { payment } = event.detail;
> >   document.getElementById('my-custom-error').style.display = 'none';
> >   window.location.href = `/order/confirmation?id=${payment.orderId}`;
> > });
> >
> > checkout.addEventListener('primer:payment-failure', (event) => {
> >   const { error, payment, paymentMethodType } = event.detail;
> >   const customErrorElement = document.getElementById('my-custom-error');
> >   customErrorElement.textContent = error.message;
> >   customErrorElement.style.display = 'block';
> >
> >   console.error('Payment failed:', {
> >     code: error.code,
> >     message: error.message,
> >     diagnosticsId: error.diagnosticsId,
> >     origin: error.origin,
> >   });
> > });
> > ```
> >
> > Before showing a retry option in this handler, check the error's `code` and `origin` — see [Deciding whether to retry](#deciding-whether-to-retry-web).
> >
> > > [!NOTE]
> > >
> > > The `onPaymentSuccess` and `onPaymentFailure` callbacks on the `primer:ready` instance still work, but are deprecated in favor of the `primer:payment-success` and `primer:payment-failure` events.
>
> > [tab] State change event
> >
> > ```javascript theme={"dark"}
> > const checkout = document.querySelector('primer-checkout');
> >
> > checkout.addEventListener('primer:state-change', (event) => {
> >   const { primerJsError, paymentFailure } = event.detail;
> >
> >   if (primerJsError || paymentFailure) {
> >     const customErrorElement = document.getElementById('my-custom-error');
> >     const errorMessage = primerJsError?.message || paymentFailure?.message;
> >     customErrorElement.textContent = errorMessage;
> >     customErrorElement.style.display = 'block';
> >   } else {
> >     document.getElementById('my-custom-error').style.display = 'none';
> >   }
> > });
> > ```
>
> This approach gives you complete control over payment failure presentation but requires you to implement the error handling logic yourself.

> [tab] Android
>
> Override the default error screen in `PrimerCheckoutSheet` with a custom Composable:
>
> ```kotlin theme={"dark"}
> PrimerCheckoutSheet(
>     checkout = checkout,
>     error = { primerError, checkoutData ->
>         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 = "Payment failed",
>                 style = MaterialTheme.typography.titleLarge,
>             )
>             Spacer(Modifier.height(8.dp))
>             Text(
>                 text = primerError.description,
>                 style = MaterialTheme.typography.bodyMedium,
>                 textAlign = TextAlign.Center,
>             )
>             checkoutData?.payment?.id?.let { paymentId ->
>                 Spacer(Modifier.height(8.dp))
>                 Text(
>                     text = "Payment reference: $paymentId",
>                     style = MaterialTheme.typography.bodySmall,
>                     textAlign = TextAlign.Center,
>                 )
>             }
>             Spacer(Modifier.height(24.dp))
>             Button(onClick = { checkout.dismiss() }) {
>                 Text("Try again")
>             }
>         }
>     },
> )
> ```
>
> ### Observing validation state
>
> For inline components, observe card form validation directly:
>
> ```kotlin theme={"dark"}
> val cardFormController = rememberCardFormController(checkout)
> val formState by cardFormController.state.collectAsStateWithLifecycle()
>
> val errors = formState.fieldErrors
> errors?.forEach { error ->
>     Log.d("Validation", "${error.inputElementType}: ${error.errorId}")
> }
> ```

> [tab] iOS
>
> `PrimerCheckout` renders the managed error screen and does not expose screen slots. For full control over how a payment failure looks, build your own layout from the composable views and drive it with a [`PrimerCheckoutSession`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session). Handle `.failure` in the `.primerCheckoutSession(_:theme:onCompletion:)` completion closure. Once checkout is showing, `.failure` arrives once per failed payment attempt and the checkout stays usable, so the shopper can try again. `.success` or `.dismissed` end the session and arrive exactly once. A `.failure` raised while the session is still starting, such as an invalid client token, also arrives once and ends the session.
>
> By default, the SDK shows its own error screen in a sheet over your layout. To stop the SDK from showing its error screen, turn it off with `PrimerUIOptions(isErrorScreenEnabled: false)` in the session's settings, as the example below does. The SDK still shows its processing sheet while a payment runs. The same setting on `PrimerCheckout` replaces its error screens with an empty view.
>
> ```swift theme={"dark"}
> struct CheckoutView: View {
>   @StateObject private var session: PrimerCheckoutSession
>   @State private var errorMessage: String?
>   @State private var paymentReference: String?
>
>   init(clientToken: String) {
>     // Turn off the SDK error screen so your layout owns the failure UI.
>     let settings = PrimerSettings(uiOptions: PrimerUIOptions(isErrorScreenEnabled: false))
>     _session = StateObject(wrappedValue: PrimerCheckoutSession(clientToken: clientToken, settings: settings))
>   }
>
>   var body: some View {
>     ScrollView {
>       PrimerCardForm()
>
>       if let errorMessage {
>         VStack(spacing: 20) {
>           Image(systemName: "xmark.circle.fill")
>             .font(.system(size: 48))
>             .foregroundColor(.red)
>           Text("Something went wrong")
>             .font(.title3.weight(.semibold))
>           Text(errorMessage)
>             .font(.body)
>             .foregroundColor(.secondary)
>             .multilineTextAlignment(.center)
>           if let paymentReference {
>             Text("Payment reference: \(paymentReference)")
>               .font(.caption)
>               .foregroundColor(.secondary)
>           }
>         }
>         .padding()
>       }
>     }
>     .primerCheckoutSession(session) { state in
>       if case let .failure(error, checkoutData) = state {
>         // `recoverySuggestion` is guidance for developers, not shopper copy.
>         errorMessage = "Your payment could not be completed. Try again or use another payment method."
>         paymentReference = checkoutData?.payment?.id
>         print("Checkout failed [\(error.errorId)] — diagnosticsId: \(error.diagnosticsId)")
>       }
>     }
>   }
> }
> ```
>
> For UIKit integrations, the same `PrimerError` arrives through [`PrimerCheckoutPresenterDelegate`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-presenter)'s `primerCheckoutPresenterDidFailWithError(_:checkoutData:)` callback.
>
> ### Observing validation state
>
> The card form is driven by [`PrimerCardFormSession`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/card-form-session), which publishes `PrimerCardFormState`. Observe it inside a [`PrimerCardForm`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/card-form/primer-card-form) slot to read validation errors per field:
>
> ```swift theme={"dark"}
> PrimerCardForm(
>   cardDetails: { session in
>     VStack(alignment: .leading) {
>       CardFormDefaults.cardNumber(session)
>       if session.state.hasError(for: .cardNumber),
>          let message = session.state.errorMessage(for: .cardNumber) {
>         Text(message).foregroundColor(.red)
>       }
>     }
>   }
> )
> ```
>
> To inspect every current field error, iterate `state.fieldErrors`:
>
> ```swift theme={"dark"}
> ForEach(session.state.fieldErrors) { error in
>   Text(verbatim: "\(error.fieldType): \(error.message)")
> }
> ```
>
> The card number, expiry, CVV, and cardholder name fields are always present. The Dashboard decides which billing address fields appear, and `session.state.configuration.billingFields` lists them. Validation runs automatically as the shopper edits each field — gate your submit button on `session.state.isValid`.

## SDK initialization failures

Before checkout can process payments, the SDK needs to initialize — validate the client token, load your Checkout Builder configuration, etc. Treat a failure here separately from the validation and payment errors above: no form has been shown yet, so there's nothing to retry other than re-initializing the SDK.

> [!NOTE]
>
> This section covers Web. See [Default error behavior](#default-error-behavior) above for Android and iOS.

When `<primer-checkout>` fails to initialize, `<primer-main>` renders its `checkout-error` slot instead of the payment methods. Provide your own content there to replace the default error screen:

```html theme={"dark"}
<primer-checkout client-token="...">
  <primer-main slot="main">
    <div slot="checkout-error">
      We could not load the checkout. Please try again later.
    </div>
  </primer-main>
</primer-checkout>
```

For error details — to log the failure or branch on its cause — listen for `primer:state-change` and read `primerJsError`:

```javascript theme={"dark"}
document.addEventListener('primer:state-change', (e) => {
  const { primerJsError } = e.detail;
  if (primerJsError) {
    console.error(primerJsError.code, primerJsError.message);
  }
});
```

| Property | Description |
| - | - |
| `code` | Stable identifier for the failure: `INVALID_CLIENT_TOKEN` (the token is malformed, expired, or was rejected — generate a new one) or `INITIALIZATION_ERROR` (any other initialization failure, e.g. your configuration failed to load) |
| `message` | Human-readable error message |
| `suggestion` | Suggested recovery action, when one applies |

If you bypass `<primer-main>` with a custom `slot="main"` on `<primer-checkout>`, handle `primerJsError` the same way.

## Choosing the right approach

| Approach | Best for | Trade-offs |
| - | - | - |
| Built-in error display | Quick implementation, consistent styling | Less customization control |
| Custom error handling | Full control over UX and design | More code to maintain |

## Best practices

> [!TIP]
>
> 1. **Always handle errors** - Never leave users without feedback after a failed payment
> 2. **Be specific where you can** - Validation errors can name the exact problem; payment declines are [intentionally generic](#not-all-failures-are-retryable), so pair them with actionable next steps instead
> 3. **Provide next steps** - Guide users on how to resolve the issue (try another card, check details, etc.)
> 4. **Verify before retrying** - Confirm the payment status server-side before treating a client-side failure as final; the payment may have succeeded. On Web, [branch on the error's `code` and `origin`](#deciding-whether-to-retry-web)
> 5. **Clear on retry** - Hide error messages when users attempt a new payment
> 6. **Log for debugging** - Capture error details including `diagnosticsId` for troubleshooting

## See also

> [card] [Error message container](https://primer.io/docs/sdk/primer-checkout-web/components/primer-error-message-container.md)
>
> Web component SDK reference

> [card] [Android SDK reference](https://primer.io/docs/sdk/android-checkout/v3.0.0-beta/api/overview.md)
>
> Android SDK API documentation

> [card] [iOS SDK reference](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/overview.md)
>
> iOS SDK API documentation

> [card] [Events guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md)
>
> Complete reference for all checkout events

> [card] [Log Errors Guide](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/log-errors-debugging.md)
>
> Capture and log payment errors for debugging


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.