The user refreshes mid-payment. Idempotency is how they are not charged twice.

Retries, refreshes, and network blips replay the same intent. An idempotency key lets the server return the first result instead of running the side effect again.

The user refreshes mid-payment. Idempotency is how they are not charged twice.

The request left the browser. The charge might already be in flight. The user hits refresh.

If “create payment” is just INSERT plus a card capture, you now have two attempts at the same human action.

This is not an edge case. It is how browsers, mobile retries, and load balancers behave.

One client intent, one server outcome

An idempotency key is a unique id for that attempt.

1. The client generates a key (a UUID is fine).
2. It sends the key with the payment request.
3. The server processes once and stores the result under that key.

A replay with the same key does not capture the card again. It returns the stored result.

Stripe and similar APIs expose this as a header. The idea is not payment-specific. Anything that must not happen twice — “place order,” “mark invoice paid,” “fire the webhook handler” — needs the same rule.

What the browser can do

If the key lives only in a React variable, a refresh mints a new one and you are back to a double charge.

Storing the key in sessionStorage for that tab keeps the same id across a refresh. A new tab is a new attempt, which is usually what you want.

The server is still the source of truth. Client storage only stops *accidental* new keys. It does not replace an auth check or a unique constraint on the key.

Takeaway

Do not make “create payment” mean “always create.”

Make it mean: this key, this outcome, at most once.

If the user can tap twice, the network can retry. Design for the retry, not for the happy path.