Skip to main content
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. When you provide an idempotency key, 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.

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 or popup 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

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

Example

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;
  }
});

Handling network loss and page refresh

Idempotency prevents duplicate charges, but it does not fully manage recovery when the client loses state. 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, it cannot reliably know if the payment succeeded, is pending, or failed.
If you need a bulletproof recovery story (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.

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