Idempotency keys for payment and signup APIs
Use idempotency keys on payment and signup endpoints so retries, double-clicks, and webhook races do not create duplicate charges or accounts.
Networks retry. Users double-tap submit. Load balancers replay requests. Without idempotency, your payment and signup APIs will eventually create duplicate customers, double charges, or twin workspaces—and support will sort it out at 2 a.m. Idempotency keys are the standard pattern for making “do this once” operations safe under retries.
This post explains how to design keys, store outcomes, and expose behavior product and frontend teams can rely on. Related reading: Background jobs and webhooks you can trust and API design frontend teams don't hate.
Payment processors document idempotency extensively; Stripe’s idempotent requests guide is the de facto reference many teams mirror in their own APIs—read the vendor docs you integrate with and align semantics.
What idempotency promises
An idempotent operation with the same idempotency key produces the same outcome as the first successful application, even if the client sends the request multiple times. The server may return the original response again instead of re-executing side effects.
Idempotency is not a substitute for auth, rate limits, or fraud checks—it sits beside them for safe retries.
Where keys matter most
Prioritize idempotency on endpoints with irreversible or expensive side effects:
- Payment intents, charges, subscription creates
- Account and workspace signup that provisions billing
- Invites that trigger paid seat charges
- Transfers, payouts, credit grants
Read-heavy GETs are naturally idempotent; POSTs that create resources are not, unless you add keys.
Signup flows that create user + org + subscription in one POST are high risk: mobile clients and Safari form resubmits love to duplicate them.
Key generation and ownership
Clients generate keys (UUID v4 is fine) and send them in a header—commonly Idempotency-Key—or in the body for constrained clients. Server-generated keys only work if the client can persist them before retry; client-owned keys are simpler for browsers.
Rules for clients:
- One key per logical user action (click “Subscribe” once → one key)
- New key for a new intent, even if amount is the same
- Persist key in memory until success or terminal failure so retries reuse it
Frontend teams should document this in integration guides alongside Auth session patterns for SaaS so signup and checkout share the same retry discipline.
Server storage and TTL
On first seen key:
- Record key hash, route, principal (user or anonymous session), request fingerprint optional
- Begin transaction; create placeholder row
status: in_progress - Execute side effects
- Store serialized response and
status: completed
On duplicate key while in progress:
- Return
409 Conflictor202 Acceptedwith retry guidance—or block until first completes (with timeout)
On duplicate key after completion:
- Return stored response with same status code as original (document this—some APIs always return 200 on replay)
Expire keys after a TTL (24–72 hours typical). Long TTL for payments; shorter for ephemeral signup if GDPR retention matters.
Use a dedicated store (Redis or SQL table) with unique constraint on (key, endpoint_scope).
Request body matching
Strong idempotency requires rejecting keys reused with different bodies (different amount, different plan). Return 422 with clear error: idempotency key mismatch.
Log mismatch attempts—they may indicate bugs or abuse.
Interaction with webhooks
Webhooks are retries from another direction. Your internal “create subscription” may succeed while the client times out; the client retries with the same key; Stripe also sends customer.subscription.created. Design so:
- Idempotent API and webhook handlers converge on same external IDs
- Background jobs and webhooks you can trust covers dedupe tables and outbox patterns
Never create a second subscription because webhook arrived before API retry finished—use unique constraints on provider IDs.
Signup-specific nuances
Signup often spans anonymous → verified user. Bind idempotency to:
- Email + signup session, or
- Client-generated key stored in
sessionStoragefor the funnel step
If email verification is async, separate “create pending user” (idempotent) from “activate” (separate key).
Product copy should explain “we’re finishing your account” on replay instead of showing a generic error.
HTTP status codes on replay
Pick a consistent policy:
- Same status as first response (Stripe-like) — simplest for clients
- Always 200 on replay with body indicating replayed — document clearly
Inconsistent status codes break client libraries that only retry on 5xx/408.
Observability and support
Log idempotency key (hashed if needed), replay flag, and latency. Support tools should lookup by key when a user insists they were charged twice—often one charge and one failed duplicate.
Metrics: replay rate, in-progress collisions, mismatch rate.
Testing
Automated tests should:
- Send same key twice; assert one side effect
- Send same key with different body; assert rejection
- Simulate slow first request; assert second waits or fails gracefully
- Expire key after TTL; assert new execution allowed
Chaos: kill worker after write but before response; client must retry safely.
API documentation product teams can ship
Public docs need:
- Header name and max key length
- TTL
- Which endpoints require keys vs recommend them
- Example client code for checkout and signup
Link to processor docs for payment-specific headers you forward.
Security considerations
Keys are not secrets but should be unguessable (UUID). Rate-limit key creation per IP/session to prevent storage stuffing.
Do not accept idempotency keys that bypass auth to another user’s resources—scope keys to authenticated principal.
When not to use keys
Low-stakes creates (draft blog post) may use simpler duplicate detection. Keys add storage and mental overhead—reserve for money and identity.
Mobile and offline clients
Mobile apps background requests and retry on connectivity return. Persist idempotency keys in secure storage keyed by checkout session ID so process death does not generate a fresh key mid-payment. Show UI state “payment processing” until terminal response, not a blank error on timeout.
Offline-first signup is rare; if you queue signups, replay with the same key when connectivity returns and surface duplicate-account errors from the server union member instead of generic failure.
Regulatory and receipt expectations
Users expect one email receipt per charge. Idempotent replays should not trigger duplicate emails—gate notifications on “first completion” flag in your domain model, not on HTTP handler entry.
For refunds and partial captures, use new keys per new financial intent; never reuse payment keys across different amounts.
Database constraints as backstop
Idempotency storage is primary; unique indexes on provider IDs and customer emails (where appropriate) are secondary. If idempotency store corrupts or TTL expires early, constraints prevent duplicate rows—return a typed conflict response instead of a 500.
Migrations that add uniqueness should be staged with backfill scripts in maintenance windows; communicate to support before enforcing on legacy duplicates.
Client SDK helpers
Ship a tiny helper that wraps fetch to attach Idempotency-Key from a supplied generator and retries only on idempotent-safe status codes. Document behavior in your public SDK so every language binding behaves consistently.
Load-test idempotency storage under parallel duplicate requests before launch traffic; unique constraint contention should return fast, deterministic responses, not connection pool exhaustion.
Publish runbooks for support that explain how to interpret duplicate charge tickets when idempotency replay logs show a single successful capture.
Closing stance
Idempotency keys turn unreliable networks into a product non-issue: users can retry, clients can backoff, and finance sees one charge per intent. Implement storage, mismatch rules, and webhook alignment once on payment and signup paths—the support savings compound.