Implement idempotency keys by attaching a unique identifier to each intended operation, storing its request fingerprint, and atomically recording the outcome alongside the business change. When a client retries, return the stored response instead of repeating the operation.
This protects against duplicate invoices, repeated credit deductions, and other costly effects when a response disappears after processing. The important part is atomic storage, because adding a request header alone doesn’t prevent duplicates.
Start by defining which requests should count as the same operation.
Key Takeaways
- Generate one key per intended operation and preserve it across retries.
- Scope keys to the authenticated caller and operation, then reject reuse with different inputs.
- Use database constraints and transactions to coordinate requests, retain replayable responses, and protect downstream effects separately.
1. Define the Scope of Your Idempotency Keys
An idempotency key connects multiple delivery attempts to one intended action. POST requests aren’t inherently idempotent, so repeated submissions can otherwise create multiple resources.

Generate the key before sending
Generate a random UUID when the user initiates an operation. Persist it with the pending request so browser refreshes, worker restarts, and automation retries reuse it.
A request could look like this:
POST /v1/invoices
Idempotency-Key: 1fd75293-9078-4cf4-bd37-8235622c57c2
Content-Type: application/json
{"customer_id":"cus_123","amount_minor":4900,"currency":"usd"}
A second intentional invoice needs a new key, even when its fields match. Therefore, don’t derive keys solely from the payload.
Bind keys to trusted tenant context
Use a scope such as (tenant_id, client_id, operation, key). Resolve tenant and client identifiers from authentication, never an untrusted header alone.
Define operation using the HTTP method and endpoint identifier. Include the target resource in the request fingerprint for updates.
Scopes differ across APIs. For example, Amazon EC2’s idempotency rules distinguish regional and zonal operations.
Authenticate and authorize every attempt, including replays. Possessing an old key must never bypass current permissions.
2. Store the Key and Request Fingerprint
Use durable storage shared by all API instances. For a PostgreSQL-backed application, a dedicated table keeps the implementation close to the business transaction.
Create a durable record
This starting schema supports tenant isolation, request matching, and response replay:
CREATE TABLE api_idempotency (
tenant_id uuid NOT NULL,
client_id text NOT NULL,
operation text NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
state text NOT NULL,
response_status integer,
response_headers jsonb,
response_body bytea,
expires_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, client_id, operation, key)
);
CREATE INDEX ON api_idempotency (expires_at);
state distinguishes processing from completed work. Response columns remain empty until completion. Store serialized response bytes when exact body replay matters.
Set key-length and request-size limits before database work. Also protect stored responses as customer data, with access controls and an appropriate retention policy.
Reject mismatched payloads
Build a SHA-256 fingerprint from the method, endpoint, relevant query parameters, and validated request body.
Canonicalize JSON deterministically so object-property order doesn’t create false mismatches. Apply defaults consistently and version the fingerprinting rules when behavior changes.
If the scoped key exists but its fingerprint differs, reject the request. For this API, document 422 Unprocessable Content with an error code such as idempotency_payload_mismatch.
Never overwrite the original record or execute the changed request. A corrected business operation needs a new key.
3. Claim and Execute the Operation Atomically
A separate lookup followed by an insert creates a race: two servers can both observe that the key is absent.
Instead, let the database’s unique constraint decide which request owns execution.

For a short operation that changes only your database:
- Authenticate, authorize, and validate the request before opening a transaction.
- Attempt the idempotency insert using the scoped unique key.
- If inserted, perform the business writes and store the completed response within that transaction.
- Commit before sending the response. If the insert loses, load the existing record and compare its fingerprint before replaying.
The claim can use:
INSERT INTO api_idempotency
(tenant_id, client_id, operation, key,
request_hash, state, expires_at)
VALUES ($1, $2, $3, $4, $5, 'processing', $6)
ON CONFLICT DO NOTHING
RETURNING key;
Under PostgreSQL’s default Read Committed isolation, a competing insert can wait for the first transaction. After it finishes, use a subsequent query to load the committed record.
Keep lock waits bounded. If waiting exceeds your limit, return a documented 409 Conflict indicating that processing is underway.
Commit the business change and replay record together. Otherwise, a crash can leave a completed action without evidence that it already happened.
If the transaction rolls back, both writes disappear, allowing the same key to retry safely.
4. Replay Responses and Define Retry Behavior
Clients need predictable answers for completed operations, temporary failures, and requests still underway. Document these outcomes as part of your API contract.
Return the recorded outcome
For a completed invoice creation, replay the original 201 status and serialized response:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/invoices/inv_123
{"id":"inv_123","status":"draft"}
Store an allowlist of replayable headers, such as Content-Type and Location. Don’t replay cookies, authentication headers, or connection-specific headers.
Avoid rebuilding the response from current invoice data. The resource may have changed since creation, but the retry concerns the original operation.
Stripe’s API v1 idempotency behavior saves the first status and body after endpoint execution begins, including 500 responses. Validation failures and conflicts with an executing request don’t produce saved results.
Separate safe retries from uncertain outcomes
For your database-only flow, a confirmed rollback permits another attempt using the same key. A committed operation returns its saved result.
Clients should reuse the key after connection failures or timeouts. Use exponential backoff with jitter, configurable delays, and a bounded retry window. Treat rate limits according to your documented retry policy.
A timeout doesn’t prove the server failed. Similarly, an external provider timeout may leave an uncertain outcome that requires reconciliation before further execution.
A durable business rejection can be replayed too. Decide which failures are terminal rather than caching every error indiscriminately.
5. Set Expiration and Cleanup Rules
Choose retention based on your longest supported retry or recovery window. Include queued jobs, offline clients, and controlled manual recovery.
Publish that window in your API documentation. Once a key expires and its record disappears, a later request can execute again.
Stripe API v1 allows keys to be pruned once they’re at least 24 hours old. That’s a provider policy, not a default every SaaS should copy.
Set expires_at when completing the operation so slow processing doesn’t consume its replay window. Then delete expired terminal records in small batches using the expiration index.
Don’t automatically delete unresolved processing records. For asynchronous operations, use leases, ownership tokens, and recovery logic so an abandoned worker can’t resume after another worker takes over.
Where duplicates would remain harmful indefinitely, add a permanent business constraint, such as a unique external invoice reference. Temporary replay storage cannot replace that protection.
6. Protect Downstream Effects Separately
A local transaction can’t atomically commit a database change and an unrelated payment-provider request.
For asynchronous work, write the business record, idempotency result, and an outbox event in one transaction. A worker then delivers that durable event.
Use a stable downstream idempotency key derived from the operation identifier and downstream action. Preserve it across delivery attempts.
Also carry trusted tenant and connection context into the worker. A customer-specific integration must use that customer’s credentials and authorization rules.
For inbound webhooks, deduplicate provider event IDs within their source and tenant scope. A replayed billing event shouldn’t send another reminder or deduct credits again. Usage metering benefits from immutable event IDs and a durable ledger.
AWS’s guidance on safe retries explains why caller-provided identifiers help services recognize repeated intent. Your API’s key alone doesn’t guarantee exactly-once effects across external systems.
7. Test the Failure Paths Before Release
A successful first request proves little about retry safety. Test the points where execution and delivery separate.
- Send concurrent requests with the same key and assert one business mutation. Then change the payload and assert rejection.
- Interrupt the connection after commit but before response delivery. Retry and compare the status, body, and stored resource count.
- Crash a worker during downstream delivery. Also test expired keys, cross-tenant reuse, and revoked permissions before replay.
Keep independent business uniqueness constraints too. For example, provisioning retries shouldn’t silently become email-based upserts that attach identities to the wrong account.
Track replay counts, fingerprint mismatches, lock waits, and unresolved operations. Log tenant, operation, and correlation identifiers without exposing credentials or full sensitive payloads.
AWS’s reliability recommendations place idempotency within failure handling. Keep these tests in your regression suite whenever transaction boundaries change.
Frequently Asked Questions
Is an idempotency key the same as an API key?
An API key authenticates a caller. An idempotency key identifies one intended operation across attempts. It isn’t a credential and must never grant access by itself.
Can Redis handle idempotency storage?
Yes, but an atomic Redis claim doesn’t atomically commit a separate PostgreSQL business write. For database-only mutations, storing both records in PostgreSQL avoids that coordination gap.
Conclusion
Reliable idempotency keys need a defined scope, request matching, and atomic persistence of the business change and its response. Retry behavior and expiration must be explicit parts of the contract.
Start with one database-backed endpoint and test a lost response after commit. Extend the same operation identity into background jobs and downstream calls, with separate safeguards wherever another system can produce side effects.