Idempotency Keys for POST APIs in Spring Boot (Stripe-Style) with Redis and Postgres
A client times out and retries a payment. Build Stripe-style Idempotency-Key handling in Spring Boot 4.1 with a Redis store and a Postgres store, and see what each does with replays, changed bodies, concurrent duplicates, expiry and a crash.
A mobile app sends POST /payments. The request leaves the phone, the lift goes into a tunnel, and 30 seconds later the app reports a timeout. The payment may have gone through, or it may not; the app cannot tell, so it does the sensible thing and sends the request again. If your API treats every POST as brand new, that sensible thing has just charged somebody twice.
An idempotency key is the fix that Stripe popularised and that an IETF working-group draft is trying to standardise. The client attaches a unique value to the operation, the server remembers it, and a repeat of the same operation is answered from memory instead of being executed again. This post builds one in Spring Boot, with two interchangeable stores, Redis and PostgreSQL, and runs every scenario against both so you can see what each one does when things go wrong.
Tested against: Spring Boot 4.1.2 (the newest GA; 4.2.0-RC2 is a release candidate), JDK 25 (Temurin 25.0.5), Redis 7.0.15, PostgreSQL 16.15. The Idempotency-Key header is still an Internet-Draft, not an RFC: the datatracker page I checked on 8 November 2026 lists revision 08, dated 22 October 2026, as an active draft of the HTTPAPI working group. Every line of output below comes from the test suite in the idempotency module of the companion repository.
Why a retry is safe for the network and dangerous for your database
Networks lose messages in both directions. If the request is lost, retrying is exactly right. If the response is lost, the work already happened and retrying repeats it. From the client’s chair these two cases look identical: silence. A POST is the method that creates things, so it is also the method where a repeat does damage. (GET, PUT and DELETE are defined as idempotent, meaning that repeating them has the same effect as sending them once; POST is not.)
The diagram is the whole problem. Nothing was lost; the client simply stopped waiting after 300 ms while the server needed 800 ms. The test that produced it is in StoreContractTest, and it calls the endpoint that has no protection, the plain controller below (PaymentController.java):
@PostMapping({"/payments", "/payments/unprotected"})
ResponseEntity<Payment> create(@RequestBody PaymentRequest request) throws InterruptedException {
if (faults.slowMillis > 0) {
Thread.sleep(faults.slowMillis);
}
long id = jdbc.sql("""
INSERT INTO payments (customer, amount, currency)
VALUES (:customer, :amount, :currency) RETURNING id""")
.param("customer", request.customer())
.param("amount", request.amount())
.param("currency", request.currency())
.query(Long.class)
.single();
if (faults.crashAfterBusiness) {
throw new SimulatedCrash();
}
return ResponseEntity.status(201)
.body(new Payment(id, request.customer(), request.amount(), request.currency()));
store: redis (not used: this endpoint has no filter)
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2 (the retry) -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
Two rows, one intention. Adding a retry library on the client makes this more likely, not less: retries are the thing that makes it happen. (Framework 7’s @Retryable has the same property; see the post on it.)
Going deeper: retries from the other side
The HTTP service clients post measures a retried POST creating twice from the client side, with the retry interceptor in the picture.
A gateway, a load balancer or a message broker can all repeat a request without your client ever knowing, so “my client does not retry” is not a guarantee.
An idempotency key turns “do this” into “make sure this has been done”
The client generates a random value (a UUID is the usual choice) once per logical operation and sends it in an Idempotency-Key header. It reuses that same value on every retry of that operation and a new one for the next operation. The server stores, per key, a fingerprint of the request and, once it has finished, the response. When a request arrives it asks four questions, and the answer decides what happens:
Read the diagram top to bottom. A missing header is a client bug (400). A key the server has never seen is a new operation: claim it, run the handler, remember the response. A key it has seen is a repeat, and then three things can be true: the body changed (the client is misusing the key, 422), the first request is still running (a concurrent duplicate), or it finished (replay the stored answer). The status codes are the ones the IETF draft uses; I read revision 07 of the text and then checked that 08 is the latest, but I did not read 08 itself.
Situation
This module
Source of the rule
header missing
400
the draft: 400 for an operation that requires a key
key reused with a different payload
422
the draft: 422
retry while the original is still running
409 + Retry-After (Redis)
the draft: 409
retry after the original finished
the stored response, any status below 500
the draft: return the earlier result; Stripe v1 stores the status and body, including 500s
how long keys live
24 h by default, configurable
Stripe v1 may prune keys at least 24 hours old; the draft leaves expiry to the resource
maximum key length
255 characters
Stripe
Where this module deliberately differs from Stripe. Stripe’s v1 API stores the response of a failed request too, 500s included. This module stores nothing for a 5xx and releases the key, so the client can retry the operation. Neither is wrong; the right choice depends on whether your handler can leave half-done state behind. Stripe also does not store a result when parameter validation fails, which matches the idea that a request that never started has nothing to replay. The Idempotent-Replayed: true header is my own addition so tests (and clients) can tell a replay from a fresh answer; the draft does not define one.
Going deeper: what the draft and Stripe each say
Stripe’s own description is in Idempotent requests; it also says GET and DELETE need no key because they are idempotent by definition.
The draft is draft-ietf-httpapi-idempotency-key-header. Revisions 03 and 05, the two I looked at, say the header value is a Structured Fields string, which in practice means it is sent in double quotes. This module treats the header value literally, so "abc" and abc would be two different keys.
The draft marks the fingerprint (section 2.4) as optional and lets the server choose how to compute it; the next section shows why you want one.
The smallest thing that works: one filter and one store interface
Everything above lives in a servlet filter (IdempotencyFilter.java) so the controller does not change. The filter only decides; where keys are kept is behind a one-method interface (IdempotencyStore.java). It reads the header, fingerprints the request, hands both to the store together with “the rest of the filter chain” as a callback, and writes whatever comes back.
byte[] body = request.getInputStream().readAllBytes();
String fingerprint = fingerprint(request, body);
IdempotencyStore.Outcome outcome;
try {
outcome = store.handle(key, fingerprint, () -> {
ContentCachingResponseWrapper captured = new ContentCachingResponseWrapper(response);
chain.doFilter(new CachedBodyRequest(request, body), captured);
return new IdempotencyStore.Captured(
captured.getStatus(), captured.getContentType(), captured.getContentAsByteArray());
});
} catch (RuntimeException | IOException | ServletException e) {
throw e;
} catch (Exception e) {
throw new ServletException(e);
}
switch (outcome.kind()) {
case PROCESSED -> write(response, outcome.response(), false);
case REPLAYED -> write(response, outcome.response(), true);
case IN_PROGRESS -> {
response.setHeader("Retry-After", "1");
problem(response, 409, "A request is outstanding for this Idempotency-Key");
}
case MISMATCH -> problem(response, 422, "Idempotency-Key was already used with a different request");
Two details matter. The body can be read only once from a servlet request, so the filter reads it and passes a copy to the controller (CachedBodyRequest.java). And the handler runs against a buffered response, so nothing reaches the client until the store has recorded it. The happy path, from 01-replay-redis.txt:
store: redis
POST /payments key=k-1 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
POST /payments key=k-1 (retry) -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
what the store holds: string beginning "DONE", expiring: true
The retry gets the same id and body, the table has one row, and the header says it was a replay. The same test against Postgres (01-replay-postgres.txt) produces the same first three lines and a different last one: a row with status COMPLETED instead of a Redis string.
Going deeper: the pieces around the filter
The filter is skipped unless the method is POST and the path is exactly idempotency.path; the same controller is also mapped to /payments/unprotected so the tests can show a retry with no protection.
A 5xx from the handler is never stored, in either store. Redis deletes the lock; Postgres marks the transaction rollback-only so the payment and the key both disappear.
Fingerprint the request, or a reused key becomes a silent bug
A key says “this is the same operation”. It does not prove it. A client bug that reuses one key for two different payments would otherwise get the first payment’s response back for the second one, and nobody would notice until the books did not add up. So the store remembers a fingerprint with the key: here, SHA-256 of the method, the path and the exact body bytes (fingerprint()).
store: redis
key=k-2 amount 1999 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
key=k-2 amount 2999 -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-2 same JSON, extra spaces -> HTTP 422 replayed=- retry-after=- {"title":"Idempotency-Key was already used with a different request","status":422}
key=k-3 amount 2999 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":2999,"currency":"INR"}
payments in the table: 2
The first two lines are the point: the same key with a different amount is a 422, the payment table does not change. The third line is the trap. The JSON is semantically identical and only has extra spaces, and it is rejected too, because the fingerprint is over bytes. That is the conservative choice, and for clients that always serialise the same way it is fine. Output from 02-mismatch-redis.txt.
Scope keys to the caller in a real system. This module treats a key as global. If two customers can pick the same key, one of them replays the other’s response, which is a data leak. Prefix the stored key with the authenticated principal or tenant. I did not build that here, so there is no transcript for it.
Going deeper: canonical JSON and the 255 character limit
To make whitespace and key order irrelevant, parse the body to a tree and serialise it in a canonical form before hashing. That adds a parse to every request and changes which client bugs you tolerate, so it is a decision, not a default.
Missing, blank and over-long keys are 400s. The boundary is exercised in 03-missing-key-redis.txt: 255 characters is accepted, 256 is not.
Two requests, one key, same instant: the in-flight problem
The retry in section 1 arrived while the first request was still running. That is the hard case: there is no stored response to replay yet. The store has to make claiming the key atomic, so exactly one request runs the handler. Redis and PostgreSQL do this differently.
The Redis store claims with a single command, SET key value NX PX ttl: it succeeds for exactly one caller. Everyone else reads the value, sees IN_PROGRESS and answers 409 (RedisIdempotencyStore.java):
for (int attempt = 0; attempt < 3; attempt++) {
if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(redisKey, lock, props.inProgressTtl()))) {
return runAndRecord(redisKey, lock, handler);
}
String existing = redis.opsForValue().get(redisKey);
if (existing == null) {
continue; // it expired between the two calls; try to take it
}
String[] parts = existing.split("\n", 5);
if (!parts[1].equals(fingerprint)) {
return Outcome.of(Kind.MISMATCH);
}
if (parts[0].equals("IN_PROGRESS")) {
return Outcome.of(Kind.IN_PROGRESS);
}
return new Outcome(Kind.REPLAYED, new Captured(
Integer.parseInt(parts[2]),
parts[3].isEmpty() ? null : parts[3],
parts[4].getBytes(StandardCharsets.UTF_8)));
}
return Outcome.of(Kind.IN_PROGRESS);
The Postgres store claims with one statement that inserts the key row, or takes over a row that has already expired, and affects zero rows if a live one exists (PostgresIdempotencyStore.java):
int claimed = jdbc.sql("""
INSERT INTO idempotency_keys (idem_key, fingerprint, status, expires_at)
VALUES (:key, :fp, 'IN_PROGRESS', clock_timestamp() + (:ttlMs * interval '1 millisecond'))
ON CONFLICT (idem_key) DO UPDATE
SET fingerprint = EXCLUDED.fingerprint, status = 'IN_PROGRESS',
response_status = NULL, content_type = NULL, response_body = NULL,
created_at = clock_timestamp(), expires_at = EXCLUDED.expires_at
WHERE idempotency_keys.expires_at < clock_timestamp()""")
.param("key", key)
.param("fp", fingerprint)
.param("ttlMs", props.ttl().toMillis())
.update();
store: redis
10 requests, same key, sent together, 500 ms of work each
201 created now : 1
201 replayed : 0
409 in progress : 9
payments in the table: 1
store: postgres
10 requests, same key, sent together, 500 ms of work each
201 created now : 1
201 replayed : 9
409 in progress : 0
payments in the table: 1
Both pay once. On Redis the nine losers are told “try again” at once. On Postgres they are not told anything: the second INSERT waits for the first one’s uncommitted row, and when that commits the waiting requests find a finished response and replay it. For a client that just wants its answer, the Postgres behaviour is friendlier; for a server with a limited thread pool, nine threads parked on a lock are a cost. That the nine end up replaying is what the transcript shows; the waiting is how PostgreSQL treats an insert that conflicts with another transaction’s uncommitted row, and I did not measure how long a pool of waiting requests can last before something else gives up.
store: redis
attempt 1: client gave up after 300 ms (the server is still working)
attempt 2, 300 ms later -> HTTP 409 replayed=- retry-after=1 {"title":"A request is outstanding for this Idempotency-Key","status":409}
attempt 3, after the first finished -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
That is the section 1 scenario again, now with a key, on Redis (04b-timeout-retry-keyed-redis.txt): the retry 300 ms later is a 409, the one after the first finished is a replay, and there is one payment. On Postgres the retry in the middle is a 201 replay instead of a 409 (04b-timeout-retry-keyed-postgres.txt).
Going deeper: the Lua scripts and the retry loop
Finishing and releasing a Redis lock are compare-and-set Lua scripts (CAS_SET, CAS_DELETE in RedisIdempotencyStore.java): they only act if the stored value is still this request’s own lock, so a request that outlived its lock cannot overwrite or delete a newer owner’s.
The for loop above retries the claim up to three times because the key can expire between a failed SET NX and the following GET.
The value layout (IN_PROGRESS and DONE records) is in the Javadoc of the class. It is plain text, not JSON, so the module needs no serialiser.
How long is “the same request”? Expiry
Keys cannot live forever: they would fill Redis or the table. Stripe’s v1 API says it may remove keys that are at least 24 hours old and treats a reused key after that as a new request; the draft leaves it to each API to choose and document an expiry. The module defaults to 24 hours, and the tests set 2 seconds so you can watch it happen (06-expiry-redis.txt):
store: redis, ttl: 2 s
first request, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
immediately after, key=k-6 -> HTTP 201 replayed=true retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
after sleeping 2.5 s, key=k-6 -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
After the TTL the same key pays again. That is correct behaviour and it is exactly why the TTL must be longer than any client’s retry window: a client that retries for two days against a one-day TTL will eventually get a second payment.
Going deeper: cleanup differs by store
Redis removes keys itself (PX on every write). Nothing to run.
Postgres rows stay until something deletes them. The claim statement reclaims an expired row for the same key, but rows for keys that are never seen again accumulate. The schema indexes expires_at (schema.sql) so a periodic DELETE ... WHERE expires_at < now() is cheap; I did not build or run that job.
Expiry is measured with clock_timestamp(), not now(). now() is the start of the transaction, so a request slower than the TTL would write a key that was already expired when it committed.
Where it goes wrong: the crash window
Everything so far assumed the process survives. Now kill it at the worst moment: after the payment has been written and before the idempotency layer has recorded the response. The module has a switch for this (FaultInjector.java and SimulatedCrash.java; delete both before shipping).
With Redis the payment and the key are two separate systems, so there is a moment where one has happened and the other has not. The lock is still held, so retries get 409. When the lock expires, nothing records that the payment happened and the next retry runs the handler again. With Postgres both writes are in one transaction, so the crash undoes both. Redis first (07-crash-window-redis.txt), then Postgres (07-crash-window-postgres.txt):
store: redis, in-progress ttl: 2 s
attempt 1 (process dies after the write) -> HTTP 500 replayed=- retry-after=- {"timestamp":"2026-11-08T18:16:50.214Z","status":500,"error":"Internal Server Error","path":"/payments"}
payments in the table: 1
attempt 2, immediately -> HTTP 409 replayed=- retry-after=1 {"title":"A request is outstanding for this Idempotency-Key","status":409}
payments in the table: 1
attempt 3, after the lock expired -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
store: postgres, in-progress ttl: 2 s
attempt 1 (process dies after the write) -> HTTP 500 replayed=- retry-after=- {"timestamp":"2026-11-08T18:16:36.198Z","status":500,"error":"Internal Server Error","path":"/payments"}
payments in the table: 0
attempt 2, immediately -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
attempt 3 -> HTTP 201 replayed=true retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 1
Redis ends with two payments for one intention, which is the exact thing the whole layer exists to prevent. Postgres ends with one. (The first response here is a 500 with a timestamp in its body, the only line in the transcripts that changes between runs.)
A rolled-back insert still burns an id. In the Postgres transcript the surviving payment has id 2, not 1. The crashed attempt inserted row 1, the transaction rolled back, and the sequence does not roll back with it. If anything downstream assumes gap-free ids, this will surprise it.
A related failure needs no crash at all: a handler that is simply slower than the lock. With a 2 second lock and a 3.2 second handler, a second request that arrives at 2.5 seconds finds no lock and runs the handler again (08-slow-handler-redis.txt):
store: redis, in-progress ttl: 2 s, handler takes 3.2 s
request A (t=0 s) -> HTTP 201 replayed=- retry-after=- {"id":1,"customer":"cus_42","amount":1999,"currency":"INR"}
request B (t=2.5 s, same key) -> HTTP 201 replayed=- retry-after=- {"id":2,"customer":"cus_42","amount":1999,"currency":"INR"}
payments in the table: 2
requests that finished after their lock expired: 2
what the store holds afterwards: nothing (EXISTS idem:k-8 = 0)
Two payments, and the final line is the surprise: nothing is stored afterwards. Request A finished after its lock expired, request B took the lock and also outlived it, so neither could record a result. A third retry would pay again. On Postgres the same test gives one payment and a replay (08-slow-handler-postgres.txt). The practical rules are to make the lock longer than your worst-case handler time and to know that this is what a TTL-based lock costs you.
Going deeper: when the real effect is outside your database
If the payment is a call to an external gateway, no transaction of yours can cover it, so the Postgres advantage disappears. The usual answer, which I did not build or test here, is to pass your key to the gateway as its idempotency key so that the gateway deduplicates the effect itself.
A crashed Redis request leaves its lock until the TTL. A shorter in-progress-ttl shortens the window of 409s but widens the window for the duplicate. Which error you prefer depends on whether a duplicate or a stall costs more.
Redis itself can lose acknowledged writes on failover or with persistence off. The tests use a throwaway server with persistence disabled; this module does not test Redis failure modes.
Redis or Postgres?
Redis store
Postgres store
claim
SET NX PX, one round trip
one INSERT ... ON CONFLICT inside the request’s transaction
concurrent duplicate
409, client retries
waits, then replays (transcript 05)
payment and key commit together
no (crash window, transcript 07)
yes
slow handler
lock can expire under it (transcript 08)
no expiry while the transaction is open
expiry
automatic
a job you write
holds a database connection while the handler runs
no
yes, for the whole request
If the business write is a row in the same Postgres, use the Postgres store: the crash window closes, which is the one failure the Redis store cannot fix. Choose Redis when the effect is outside your database, when you cannot afford to hold a transaction (and a connection) open for the whole request, or when you already run Redis and accept the window with your eyes open. The last row of the table is a cost I reasoned from how the store works; I did not measure pool exhaustion.
Running it, and one trap if you are in India
The module needs a PostgreSQL and a redis-server binary; the README has the three commands. One thing cost a failed first run: pgjdbc sends the JVM’s default time zone to PostgreSQL as the TimeZone startup parameter. A JVM running with TZ=Asia/Calcutta, the long-standing name for Indian Standard Time, could not connect at all to this server (00-timezone-trap.txt):
The server only knows the current name, Asia/Kolkata; the legacy alias is not in its tzdata. The tests pin -Duser.timezone=UTC in the surefire configuration (pom.xml); for the application itself, start it with -Duser.timezone=Asia/Kolkata. I only confirmed this against the Ubuntu 24.04 PostgreSQL 16.15 package shown above, not against other distributions.
Should you build this at all?
Often a unique constraint is enough. If the client already has a natural identifier for the operation (an order number, a cart id), put a UNIQUE constraint on it and answer the duplicate with the existing row. That is idempotency with no header, no store and no TTL. A generic key layer earns its keep when the client has no such identifier, when the operation touches several systems, or when you need to replay the exact original response. Also, an idempotency layer does not make a handler safe to run twice; it makes it unlikely to. If a crash window matters (and for money it does), use the Postgres store or push the key down to whatever performs the effect.
No Comments yet!