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

## How it works

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

<Tabs>
  <Tab title="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>

  <Tab title="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>

  <Tab title="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 same key is reused for the entire payment lifecycle and is automatically cleared after the payment completes (on success or failure), so the next attempt provides a fresh key.

    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>
</Tabs>

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

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

<Tabs>
  <Tab title="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>

  <Tab title="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.error.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>

  <Tab title="iOS">
    Hold a [`PrimerCheckoutSession`](/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 checkout is dismissed or fails
    4. Handle duplicate key errors explicitly

    ```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 for retries.
        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 key for any future attempt
                case .success:
                    keys.rotate()
                // 3) Handle the duplicate key error explicitly.
                //    errorId is a stable, kebab-case identifier.
                case .failure(let error):
                    if error.errorId == "idempotency-key-already-exists" {
                        keys.rotate()
                    }
                // 4) User dismissed checkout — rotate key for next attempt
                case .dismissed:
                    keys.rotate()
                default:
                    break
                }
            }
        }
    }
    ```

    <Note>
      `errorId` is a stable, kebab-case identifier (for example `"payment-cancelled"`), not the SCREAMING\_SNAKE\_CASE API error code. Confirm the exact id the SDK emits for a duplicate key with the Primer team, or branch on `error.underlyingErrorCode` if you need to match the raw API code.
    </Note>

    <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.
    </Tip>
  </Tab>
</Tabs>

## Handling network loss and state recovery

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

<Tabs>
  <Tab title="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>

  <Tab title="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>

  <Tab title="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
  </Tab>
</Tabs>

***

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

***

## See also

<CardGroup cols={2}>
  <Card title="Idempotency Key" icon="key" href="/docs/api-reference/get-started/idempotency-key">
    How idempotency keys work at the API level
  </Card>

  <Card title="Events guide" icon="bolt" href="/docs/checkout/primer-checkout/configuration/events">
    Platform-specific events and interception patterns
  </Card>

  <Card title="Redirect after payment" icon="arrow-right" href="/docs/checkout/primer-checkout/guides-and-recipes/redirect-after-payment">
    Navigate to a confirmation page after payment
  </Card>

  <Card title="Log errors for debugging" icon="bug" href="/docs/checkout/primer-checkout/guides-and-recipes/log-errors-debugging">
    Capture and log payment errors for debugging
  </Card>
</CardGroup>
