> ## 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.

# Prevent duplicate payments with idempotency keys

> Pass an idempotency key in Auto flow to prevent duplicate payment creation, and learn when to reuse vs rotate it.

Pass an idempotency key when creating payments to ensure the same payment is never created twice.

For background on how idempotency keys work at the API level, see **Idempotency Key**.

## Rule of thumb

* **Same idempotency key = same payment attempt** (safe retries, no duplicates)
* **New idempotency key = new payment attempt** (creates a new payment)

If you reuse a key for a new attempt, it will trigger a failure with error code `IDEMPOTENCY_KEY_ALREADY_EXISTS`. On iOS, this failure arrives as a generic error without that code.

## How it works

In Auto flow, the SDK creates the payment by calling the Payments API internally.

> [tab] Web
>
> When you provide an idempotency key via `continuePaymentCreation({ idempotencyKey })` in the `primer:payment-start` event, the SDK includes it as the `X-Idempotency-Key` header on:
>
> * `POST /payments` (create payment)
>
> If the same request is retried with the same key:
>
> * The API will not create a second payment.
> * It will fail with a `409 Conflict` response.

> [tab] Android
>
> When you provide an idempotency key via `handler.continuePaymentCreation(idempotencyKey)` in the `beforePaymentCreate` gate, the SDK includes it as the `X-Idempotency-Key` header on:
>
> * `POST /payments` (create payment)
>
> The key is passed per attempt from the payment-creation gate — it is no longer a parameter on `rememberPrimerCheckoutController`.
>
> If the same request is retried with the same key:
>
> * The API will not create a second payment.
> * It will fail with a `409 Conflict` response.

> [tab] iOS
>
> When you provide an idempotency key via `decisionHandler(.continuePaymentCreation(withIdempotencyKey:))` in your `PrimerCheckoutSession`'s `onBeforePaymentCreate` handler, the SDK includes it as the `X-Idempotency-Key` header on:
>
> * `POST /payments` (create payment)
>
> The SDK asks for the key once per payment attempt, through `onBeforePaymentCreate` or the `idempotencyKey` provider.
>
> * A key you pass to `.continuePaymentCreation(withIdempotencyKey:)` takes precedence over the provider.
> * The provider supplies the key when there is no handler, or when the handler continues without a key.
> * The SDK sends the key only on the create-payment request and clears it when that request returns, whether it succeeds or fails.
> * Resume requests, for example after 3DS or a redirect, do not carry the key.
> * To retry the same attempt with the same key, return that key again when the SDK asks for it.
>
> If the same request is retried with the same key:
>
> * The API will not create a second payment.
> * It will fail with a `409 Conflict` response.

### Why key rotation matters

For redirect and 3DS flows, the payment is created **before** the user completes the external step.

If the user abandons the redirect or retries after a failed authentication, this becomes a **new payment attempt**, and the key must be rotated.

If you do not rotate the key, the retry will fail with an error code `IDEMPOTENCY_KEY_ALREADY_EXISTS`. On iOS, the retry fails with a generic error.

## When to reuse vs rotate

### Reuse the same key

Reuse the current key when you are retrying the **same attempt**, for example:

* transient network retry
* the SDK retries the same request
* resume of the same attempt

### Rotate the key

Rotate to a new key when the user starts a **new attempt**, for example:

* user abandons a redirect, popup, or external app flow and tries again
* user cancels the payment and tries again
* failed 3DS and the user retries as a new attempt
* user switches payment method (optional but recommended)

## Recipe

> [tab] Web
>
> In this recipe we:
>
> 1. Intercept payment creation with `primer:payment-start`
> 2. Provide an `idempotencyKey` per attempt
> 3. Rotate the attempt when the user abandons a redirect flow using `primer:payment-cancel`
> 4. Handle duplicate key errors explicitly
>
> ```js theme={"dark"}
> let attempt = 1;
> let currentIdempotencyKey = null;
>
> // Optional: track whether we should rotate on the next attempt
> let shouldRotateOnNextStart = false;
>
> // You can generate locally (UUID) or fetch from your backend.
> // Backend is recommended if you want stronger guarantees across refresh/outages.
> async function getIdempotencyKeyForAttempt(attemptNumber) {
>   // Example 1: local generation
>   // return crypto.randomUUID();
>
>   // Example 2: backend generation (recommended)
>   const res = await fetch(`/api/idempotency-key?attempt=${attemptNumber}`, { method: 'POST' });
>   const data = await res.json();
>   return data.idempotencyKey;
> }
>
> // 1) Inject the key when the payment starts
> checkout.addEventListener('primer:payment-start', async (event) => {
>   event.preventDefault();
>
>   // If we detected a new attempt boundary, rotate attempt counter
>   if (shouldRotateOnNextStart) {
>     attempt += 1;
>     currentIdempotencyKey = null;
>     shouldRotateOnNextStart = false;
>   }
>
>   // Create the key once per attempt and reuse it for retries of the same attempt
>   if (!currentIdempotencyKey) {
>     currentIdempotencyKey = await getIdempotencyKeyForAttempt(attempt);
>   }
>
>   event.detail.continuePaymentCreation({ idempotencyKey: currentIdempotencyKey });
> });
>
> // 2) Detect the user abandoning a redirect or popup based flow
> // This event is the key signal to know the next click is a new attempt.
> checkout.addEventListener('primer:payment-cancel', () => {
>   shouldRotateOnNextStart = true;
> });
>
> // 3) Handle the duplicate key error explicitly
> checkout.addEventListener('primer:payment-failure', (event) => {
>   const { error } = event.detail;
>
>   if (error?.code === 'IDEMPOTENCY_KEY_ALREADY_EXISTS') {
>     // This means either:
>     // - The cancel event was not handled (and the key was not rotated), or
>     // - A payment was already successfully created with this key.
>
>     // Suggested UX:
>     // - Check the payment status server-side to see if a previous attempt succeeded.
>     //   If so, redirect the user to a completion/confirmation page.
>     // - Otherwise, tell the user to retry (after rotating the key).
>
>     // Ensure the next attempt uses a new key.
>     shouldRotateOnNextStart = true;
>   }
> });
> ```

> [tab] Android
>
> In this recipe we:
>
> 1. Intercept payment creation with the `beforePaymentCreate` gate
> 2. Provide an idempotency key per attempt
> 3. Rotate the key on success, dismissal, or a duplicate-key failure
> 4. Handle duplicate key errors explicitly
>
> ```kotlin theme={"dark"}
> @Composable
> fun CheckoutView(clientToken: String) {
>     val checkout = rememberPrimerCheckoutController(clientToken)
>     val state by checkout.state.collectAsStateWithLifecycle()
>
>     // Create the key once per attempt and reuse it for retries of the same attempt
>     var currentIdempotencyKey by remember { mutableStateOf<String?>(null) }
>
>     fun rotateKey() {
>         currentIdempotencyKey = null
>     }
>
>     LaunchedEffect(state) {
>         when (val s = state) {
>             // 2) Payment succeeded — rotate key for any future attempt
>             is PrimerCheckoutState.Success -> rotateKey()
>             // 3) Handle the duplicate key error explicitly
>             is PrimerCheckoutState.Failure -> {
>                 if (s.primerError.errorCode == "IDEMPOTENCY_KEY_ALREADY_EXISTS") {
>                     rotateKey()
>                 }
>             }
>             else -> Unit
>         }
>     }
>
>     PrimerCheckoutSheet(
>         checkout = checkout,
>         // 1) Inject the key when the payment starts
>         beforePaymentCreate = { handler ->
>             LaunchedEffect(Unit) {
>                 val key = currentIdempotencyKey
>                     ?: UUID.randomUUID().toString().also { currentIdempotencyKey = it }
>                 handler.continuePaymentCreation(key)
>             }
>         },
>         // 4) User dismissed checkout — rotate key for next attempt
>         onDismiss = { rotateKey() },
>     )
> }
> ```

> [tab] iOS
>
> Hold a [`PrimerCheckoutSession`](https://primer.io/docs/sdk/ios-checkout/v3.0.0-beta/api/primer-checkout-session) and supply the key through its `onBeforePaymentCreate` decision handler. In this recipe we:
>
> 1. Intercept payment creation with `onBeforePaymentCreate`
> 2. Provide an idempotency key per attempt
> 3. Rotate the key when the payment succeeds, fails or the checkout is dismissed
>
> ```swift theme={"dark"}
> import SwiftUI
> import PrimerSDK
>
> // Holds the mutable key in a reference type so the @Sendable
> // onBeforePaymentCreate closure can capture it safely (capturing a
> // View-local @State var from that escaping @Sendable closure is a
> // Swift 6 concurrency error and would not reliably drive the value).
> @available(iOS 15.0, *)
> @MainActor
> final class IdempotencyKeyStore: ObservableObject {
>     @Published private(set) var attempt = 1
>     private var currentKey: String?
>
>     // Create the key once per attempt and reuse it until the attempt ends.
>     func keyForCurrentAttempt() -> String {
>         if let key = currentKey { return key }
>         let key = UUID().uuidString
>         currentKey = key
>         return key
>     }
>
>     func rotate() {
>         currentKey = nil
>         attempt += 1
>     }
> }
>
> @available(iOS 15.0, *)
> struct CheckoutView: View {
>     @StateObject private var session: PrimerCheckoutSession
>     @StateObject private var keys = IdempotencyKeyStore()
>
>     init(clientToken: String) {
>         let store = IdempotencyKeyStore()
>         let session = PrimerCheckoutSession(clientToken: clientToken)
>         // 1) Inject the key when the payment is about to be created.
>         // `store` is a reference type, so this @Sendable closure
>         // captures it safely.
>         session.onBeforePaymentCreate = { _, decisionHandler in
>             Task { @MainActor in
>                 decisionHandler(.continuePaymentCreation(withIdempotencyKey: store.keyForCurrentAttempt()))
>             }
>         }
>         _session = StateObject(wrappedValue: session)
>         _keys = StateObject(wrappedValue: store)
>     }
>
>     var body: some View {
>         ScrollView {
>             VStack(spacing: 24) {
>                 PrimerPaymentMethods()
>                 PrimerCardForm()
>             }
>             .padding()
>         }
>         .primerCheckoutSession(session) { state in
>             switch state {
>             // 2) Payment succeeded: rotate the key for any future attempt
>             case .success:
>                 keys.rotate()
>             // 3) The attempt failed. The checkout stays open, so the user's
>             //    retry is a new attempt and needs a new key.
>             case .failure:
>                 keys.rotate()
>             // 4) User dismissed checkout: rotate the key for the next attempt
>             case .dismissed:
>                 keys.rotate()
>             default:
>                 break
>             }
>         }
>     }
> }
> ```
>
> > [!NOTE]
> >
> > The `.primerCheckoutSession` closure receives `.failure` for each failed attempt, and the checkout stays open so the user can retry. A duplicate-key rejection has no dedicated `errorId`. It arrives as a generic `.failure` whose `errorId` is `"unknown"`, and `error.underlyingErrorCode` is `nil`. You cannot branch on it, so rotate the key on every `.failure`, as the recipe does. If the network drops after the request was sent, the payment may already exist. Check its status on your server before you let the user pay again. If the user abandons a web redirect, the checkout returns to the method list and the closure receives nothing. The next attempt then reuses the key and fails once, and that `.failure` rotates the key.
>
> > [!TIP]
> >
> > If you only need a fresh key per attempt without custom logic, skip `onBeforePaymentCreate` and pass the declarative `idempotencyKey` provider when you construct the session: `PrimerCheckoutSession(clientToken: token, idempotencyKey: { UUID().uuidString })`. It is invoked once per payment attempt.

## Handling network loss and state recovery

Idempotency prevents duplicate charges, but it does **not** fully manage recovery when the client loses state.

> [tab] Web
>
> If the network drops or the page refreshes right after `createPayment` is sent:
>
> * The payment may have been created on the server, even if the client never received a response.
> * Retrying with the **same idempotency key** is the safest client-side action for the same attempt.
> * If the client lost state (e.g., after a page refresh), it cannot reliably know if the payment succeeded, is pending, or failed.
>
> If you need bulletproof recovery (across refresh or outages), you should:
>
> * Keep a server-side order state, and/or
> * Listen to webhooks, and/or
> * Use manual payment flow for full lifecycle control.

> [tab] Android
>
> If the app is backgrounded, the process is terminated, or the network drops right after payment creation is initiated:
>
> * The payment may have been created on the server, even if the app never received a response.
> * If the user re-opens the app and triggers a new payment, using the **same idempotency key** prevents a duplicate charge.
> * The client cannot reliably know if the previous payment succeeded, is pending, or failed.
>
> If you need bulletproof recovery (across app termination or outages), you should:
>
> * Persist the idempotency key and attempt counter (e.g., in `DataStore` or a local database)
> * Keep a server-side order state, and/or
> * Listen to webhooks

> [tab] iOS
>
> If the app is backgrounded, the process is terminated, or the network drops right after payment creation is initiated:
>
> * The payment may have been created on the server, even if the app never received a response.
> * If the user re-opens the app and triggers a new payment, using the **same idempotency key** prevents a duplicate charge.
> * The client cannot reliably know if the previous payment succeeded, is pending, or failed.
>
> If you need bulletproof recovery (across app termination or outages), you should:
>
> * Persist the idempotency key and attempt counter (e.g., in `UserDefaults` or a local database)
> * Keep a server-side order state, and/or
> * Listen to webhooks

***

## Common pitfalls

### Rotating the key on every click

If you generate a new key every time, you reduce the protection against true request retries.

### Reusing the same key after abandon

If the user cancels a redirect flow and tries again with the same key, the API will fail with a `409`.

### Treating idempotency errors as generic failures

`IDEMPOTENCY_KEY_ALREADY_EXISTS` is often expected behavior.\
Handle it explicitly and guide the user instead of showing a generic error.
On iOS, the SDK does not expose this code, so rotate the key on every `.failure`.

***

## See also

> [card] [Idempotency Key](https://primer.io/docs/api-reference/get-started/idempotency-key.md)
>
> How idempotency keys work at the API level

> [card] [Events guide](https://primer.io/docs/checkout/primer-checkout/configuration/events.md)
>
> Platform-specific events and interception patterns

> [card] [Redirect after payment](https://primer.io/docs/checkout/primer-checkout/guides-and-recipes/redirect-after-payment.md)
>
> Navigate to a confirmation page after payment

> [card] [Log errors for debugging](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.