When you build a payment or order API, you can’t avoid incidents like “a user double-clicked and the same order was placed twice” or “a network retry caused a double charge.” The go-to approach for preventing this is the Idempotency-Key header pattern, familiar from Stripe and others.
In this article, we’ll walk through how to implement a Filter that handles Idempotency-Key in Spring Boot, combined with Redis, including handling concurrent requests and designing TTLs.
The “idempotency key” (Idempotency-Key) covered in this article is a standard pattern for protecting POST requests with side effects, such as payments and orders. From the perspective of building a solid REST API foundation, reading this alongside How to Return Unified Error Responses in Spring Boot REST APIs and How to Implement Pagination in Spring Boot REST APIs will make it easier to keep your API design consistent. If you handle retries and notifications on the asynchronous side, also refer to How to Decouple Modules with Spring Boot’s ApplicationEvent and How to Implement Server-Sent Events (SSE) in Spring Boot.
Why REST APIs Need Idempotency
Duplicate execution happens more often than you might think. Users mash the submit button, a mobile connection drops for a moment and the client auto-retries, a load balancer resends after a timeout, and so on. The causes are varied.
For operations with side effects such as payments, orders, money transfers, and email sending, duplicate execution leads directly to financial or business damage. Sometimes a unique constraint on the DB side is enough, but for operations whose side effects occur outside the DB, such as external API calls or email sending, you have no choice but to handle it at the HTTP layer.
What Is Idempotency (Definition)
Idempotency is the property that executing the same operation any number of times produces the same result. In the context of REST APIs, it means “even if the same request with the same idempotency key (Idempotency-Key) is sent multiple times, the state change on the server happens only once, and the response returned is the same as the first one.”
At the HTTP method level, GET / PUT / DELETE are idempotent by specification. POST and PATCH, on the other hand, are not idempotent, so if you want to prevent duplicate execution, you need to introduce an idempotency key scheme using the Idempotency-Key header on the application side. Conversely, there is no need to attach an Idempotency-Key to GET requests.
How the Idempotency-Key Header Pattern Works
The mechanism is simple. The client attaches a unique key (such as a UUID) to the Idempotency-Key header for each request, and the server stores the result of the first processing (status + body) associated with that key. When a retry arrives with the same key, the server returns the stored response as-is.
Key generation must always be done on the client side. The standard practice is to issue one UUID v4 or similar per request. If the server generated the key, the same key could not be reproduced on retry, and idempotency would not hold.
This approach is being standardized by the IETF in the Idempotency-Key HTTP Header Field draft (draft-ietf-httpapi-idempotency-key-header-06, 2024), and Stripe has adopted it for many years.
Overall Architecture
In a Spring Boot application, wrapping the request and response in a Filter is the easiest way to handle this. Since we need to store the response body, the combination of OncePerRequestFilter + ContentCachingResponseWrapper is more straightforward than an Interceptor. For when to use Filter vs. Interceptor, see The Difference Between Filter and Interceptor in Spring Boot and When to Use Each.
We’ll use Redis as the store. The three deciding factors are: TTL works automatically, it serves as a consistent store in distributed environments, and setIfAbsent makes mutual exclusion easy.
Project Setup
There are two dependencies: Web and Redis.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
data:
redis:
host: localhost
port: 6379
timeout: 2s
If you’re unsure about setting up Redis, reading Spring Boot and Redis Integration Guide first will make things go smoothly.
A Wrapper That Fully Reads the Request Body
This is a subtle pitfall. ContentCachingRequestWrapper only accumulates in its internal cache the bytes that have been consumed via getInputStream(), so if you try to compute a hash before chain.doFilter, you get an empty array. It can’t be used for our purpose here, where we want the body hash at the Filter stage.
So we’ll prepare a wrapper that reads the stream fully on its own and makes it replayable.
public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public CachedBodyRequestWrapper(HttpServletRequest req) throws IOException {
super(req);
this.body = StreamUtils.copyToByteArray(req.getInputStream());
}
public byte[] getBody() { return body; }
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream in = new ByteArrayInputStream(body);
return new ServletInputStream() {
public int read() { return in.read(); }
public boolean isFinished() { return in.available() == 0; }
public boolean isReady() { return true; }
public void setReadListener(ReadListener l) {}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
}
With this, the body can be used for hash computation inside the Filter, and the handler side can also read the body without issues.
Implementing OncePerRequestFilter
We target only POST and PATCH, and let everything else pass through. We also lightly validate the key value at the entry point and reject invalid values with a 400.
@Component
public class IdempotencyFilter extends OncePerRequestFilter {
private static final String HEADER = "Idempotency-Key";
private static final Duration TTL = Duration.ofHours(24);
private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9-]{8,128}$");
private final IdempotencyStore store;
public IdempotencyFilter(IdempotencyStore store) { this.store = store; }
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String key = req.getHeader(HEADER);
String method = req.getMethod();
if (key == null || !(method.equals("POST") || method.equals("PATCH"))) {
chain.doFilter(req, res);
return;
}
if (!KEY_PATTERN.matcher(key).matches()) {
res.setStatus(HttpStatus.BAD_REQUEST.value());
res.getWriter().write("{\"error\":\"invalid Idempotency-Key format\"}");
return;
}
CachedBodyRequestWrapper reqWrapper = new CachedBodyRequestWrapper(req);
ContentCachingResponseWrapper resWrapper = new ContentCachingResponseWrapper(res);
String bodyHash = sha256(reqWrapper.getBody());
IdempotencyStore.LockResult lock = store.tryLock(key, bodyHash, TTL);
if (lock != null) {
switch (lock.state()) {
case COMPLETED -> { write(resWrapper, lock.cached()); resWrapper.copyBodyToResponse(); return; }
case PROCESSING -> {
res.setStatus(HttpStatus.CONFLICT.value());
res.setHeader("Retry-After", "1");
res.getWriter().write("{\"error\":\"request in progress\"}");
return;
}
case MISMATCH -> {
res.setStatus(HttpStatus.CONFLICT.value());
res.getWriter().write("{\"error\":\"body mismatch for same Idempotency-Key\"}");
return;
}
}
}
chain.doFilter(reqWrapper, resWrapper);
if (shouldCache(resWrapper.getStatus())) {
store.complete(key, bodyHash, resWrapper.getStatus(),
resWrapper.getContentAsByteArray(), TTL);
} else {
store.release(key);
}
resWrapper.copyBodyToResponse();
}
/** 2xxとバリデーション系の4xx(400/409/422)だけキャッシュする方針。
* 401/403/404は状態が時間で変わるため除外、5xxは再試行を許容するため除外。 */
private boolean shouldCache(int status) {
if (status >= 200 && status < 300) return true;
return status == 400 || status == 409 || status == 422;
}
}
The key point is that shouldCache deliberately excludes 401/403/404. Authorization state and target resources change over time, so returning the same response for 24 hours would cause problems.
The response for concurrent submissions is unified as 409 Conflict. 425 Too Early is also a candidate, but since handling in major HTTP clients is inconsistent, 409 + Retry-After, which is reliably interpreted, is easier to work with operationally.
Key Management and Lock Control in Redis
Now for the store side. We acquire the lock atomically with setIfAbsent, and if a value already exists, we strictly parse its contents to determine the state.
@Component
public class IdempotencyStore {
public enum State { COMPLETED, PROCESSING, MISMATCH }
public record LockResult(State state, CachedResponse cached) {}
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
public IdempotencyStore(StringRedisTemplate redis, ObjectMapper mapper) {
this.redis = redis;
this.mapper = mapper;
}
public LockResult tryLock(String key, String bodyHash, Duration ttl) throws IOException {
String redisKey = "idem:" + key;
String value = mapper.writeValueAsString(Map.of("state", "processing", "hash", bodyHash));
Boolean acquired = redis.opsForValue().setIfAbsent(redisKey, value, ttl);
if (Boolean.TRUE.equals(acquired)) return null; // 新規取得→呼び出し側で処理続行
JsonNode existing = mapper.readTree(redis.opsForValue().get(redisKey));
if (!bodyHash.equals(existing.path("hash").asText())) {
return new LockResult(State.MISMATCH, null);
}
if ("completed".equals(existing.path("state").asText())) {
return new LockResult(State.COMPLETED, toCached(existing));
}
return new LockResult(State.PROCESSING, null);
}
// complete / release / toCached / sha256 などは省略
}
This is the biggest fix compared to the previous version. Even for concurrent requests with the same key and the same body, as long as the first one is still processing, subsequent ones are always returned as PROCESSING and never proceed to chain.doFilter. This reliably stops the exact situation we want to prevent: “two were sent at the same time and two records were created.”
state and hash are strictly parsed as JSON and compared field by field, rather than using substring matching.
Note that this implementation is a combination of a distributed lock and an idempotency key. The distributed lock is the mechanism that “provides mutual exclusion for concurrent execution,” while the Idempotency-Key is the mechanism that “reproduces the result of the same operation,” and setIfAbsent lets a single Redis key serve both roles.
Filter Registration Order
Specify the target URLs and order with FilterRegistrationBean.
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<IdempotencyFilter> register(IdempotencyFilter filter) {
FilterRegistrationBean<IdempotencyFilter> bean = new FilterRegistrationBean<>(filter);
bean.addUrlPatterns("/api/payments/*", "/api/orders/*");
// Spring SecurityのFilterChainProxy(DEFAULT_FILTER_ORDER=-100)より後に動かす
bean.setOrder(0);
return bean;
}
}
If you place it before Spring Security, unauthenticated users could pollute the processing cache with just an Idempotency-Key. Always run it after authentication and authorization.
Handling TTL and Error Responses
Handling by status is as follows.
- 2xx is cached by default. This is the primary purpose.
- Only validation-type errors such as 400/409/422 are cached, because the result won’t change on retry.
- 401/403/404 are not cached, because authorization state and resource existence change over time.
- 5xx is not cached either, allowing the client to retry.
Following Stripe, 24 hours is a reasonable starting point for the TTL. Too long puts pressure on Redis, and too short fails to cover the client’s retry window.
Choosing a Status Code (409 vs 422 vs 425)
There are several options for the status code to return when a different body is sent with the same Idempotency-Key, or when the same key is resent while still in progress. Here’s a comparison table.
| Status | Primary Meaning | Usage with Idempotency-Key | Recommendation |
|---|---|---|---|
| 409 Conflict | Conflict with resource state | Widely used for body mismatch / resend while processing (Stripe approach) | ◎ Interpretation is stable across major HTTP clients |
| 422 Unprocessable Content | Semantically unprocessable request | Listed in the IETF draft as a candidate for same key with different body | ◯ Viable if explicitly specified in the API spec |
| 425 Too Early | Request sent too early | Semantically close to a resend while processing | △ Client implementation support is unstable |
In this article, we prioritize operational ease and unify both body mismatch and in-progress cases as 409 Conflict + Retry-After header. Which one to adopt as your API specification should be decided in advance in agreement with your clients.
Verifying the Behavior
POST twice with the same key and check the responses and the DB state.
KEY=$(uuidgen)
for i in 1 2; do
curl -X POST http://localhost:8080/api/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"amount":1000,"currency":"JPY"}'
done
If the second response exactly matches the first and only one record exists in the DB, it’s a success. For concurrent submission, firing the same key with around 20 parallel requests using k6 lets you observe the lock control in action. For test automation, spinning up Redis with Testcontainers is a solid choice. For the CRUD API foundation, also refer to Tutorial: Building a CRUD REST API with Spring Boot.
Considerations for Production Operation
First, key scope. Scope by user × endpoint, and include identifiers in the Redis key like idem:{userId}:{endpoint}:{key}. If made global, there’s a risk of accidentally returning another user’s response.
Next, behavior during Redis failure. Fail-closed (stop the API when Redis is down) prevents duplicate execution but reduces availability. A realistic approach is to split by business impact: fail-closed for critical operations like payments, fail-open for everything else.
Finally, logging considerations. Since the Idempotency-Key is client-generated, it may contain PII or guessable information. Rather than logging it as-is, we recommend hashing it or truncating it to the first few characters.
Combining this with concurrency control at the DB layer makes it even more robust. If you’re interested, also check out Spring Boot JPA Optimistic Locking (@Version) Implementation Guide.
Summary
Idempotency-Key is a low-cost shield for protecting critical POST requests such as payments and orders. In Spring Boot, the combination of OncePerRequestFilter and Redis lets you write it more simply than you might expect.
The key points are: fully read the body with a custom wrapper before hashing, acquire the lock with setIfAbsent and reject subsequent requests in the processing state with 409 + Retry-After, cache only 2xx and validation-type 4xx completed responses, and use a 24-hour TTL. From there, decide on scope design and failure behavior to fit your own domain.