When building payment or order APIs, you can’t avoid issues like “the user double-clicked and the same order was placed twice” or “double billing happened due to a network retry.” The classic approach to prevent this is the Idempotency-Key header pattern, familiar from services like Stripe.

In this article, I’ll explain how to implement a Filter that handles Idempotency-Key in Spring Boot combined with Redis, including concurrent request handling and TTL design.

The “idempotency key” covered in this article is a classic pattern for protecting POSTs with side effects, such as payments and orders. From the perspective of building a REST API foundation, reading this alongside How to Return Unified Error Responses in Spring Boot REST API and How to Implement Pagination in Spring Boot REST API makes it easier to maintain consistency in API design. If you handle retries and notifications on the asynchronous side, also see How to Loosely Couple Modules with Spring Boot’s ApplicationEvent and How to Implement Server-Sent Events (SSE) in Spring Boot.

Why REST APIs Need Idempotency

What is Idempotency (Definition)

Idempotency means the property that the result does not change no matter how many times the same operation is executed. In the REST API context, it means “even if you send the same request with the same Idempotency-Key multiple times, the state change on the server occurs only once, and the response returned is the same as the first time.”

By HTTP method, GET / PUT / DELETE are inherently idempotent by specification. On the other hand, POST and PATCH are not idempotent, so if you want to prevent double execution, you need to introduce an idempotency key scheme using the Idempotency-Key header on the application side.

Double execution happens more frequently than you might think. Users mash the submit button, mobile connections drop momentarily and clients automatically retry, load balancers retry after timeouts—the causes vary.

GET/PUT/DELETE are inherently idempotent by specification, so they’re not a problem. The trouble is with POST. For operations with side effects like payments, orders, transfers, and email sending, double execution directly leads to financial and operational damage.

A unique constraint on the DB side might suffice in some cases, but for operations with side effects outside the DB, such as external API calls or email sending, you have to catch them at the HTTP layer.

How the Idempotency-Key Header Pattern Works

The mechanism is simple. The client attaches a unique key (such as a UUID) to each request in the Idempotency-Key header, and the server stores the result of the initial processing (status + body) associated with the key. When a retry comes in with the same key, the saved response is returned as-is.

This scheme is being standardized in the IETF’s Idempotency-Key HTTP Header Field draft (draft-ietf-httpapi-idempotency-key-header-06, 2024), and Stripe has been using it for years.

Overall Architecture

In Spring Boot applications, intercepting requests and responses with a Filter is the easiest to handle. Since you need to save the response body, the combination of OncePerRequestFilter + ContentCachingResponseWrapper is more natural than an Interceptor. For the difference between Filter and Interceptor, see Differences and Use Cases of Spring Boot’s Filter and Interceptor.

For the store, we use Redis. The three deciding factors are: TTL works automatically, it becomes a consistent store in a distributed environment, and setIfAbsent makes exclusive control easy.

Project Setup

Two dependencies are needed: 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 uncertain about the Redis setup, reading Spring Boot and Redis Integration Guide first will help.

A Wrapper That Reads the Request Body Completely

This is a subtle pitfall. ContentCachingRequestWrapper only accumulates bytes consumed by getInputStream() in its internal cache, so trying to compute a hash before chain.doFilter returns an empty array. It can’t be used for our use case, where we need the body hash at the Filter stage first.

So we prepare a wrapper that reads the stream ourselves 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 Filter can use it for hash computation, and the handler can read the body without issues.

Implementing OncePerRequestFilter

Only POST and PATCH are targeted; the rest pass through. The key value is also lightly validated at the entrance, and invalid values are rejected with 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();
    }

    /** Policy: cache only 2xx and validation-type 4xx (400/409/422).
     *  Exclude 401/403/404 because their state changes over time; exclude 5xx to allow retries. */
    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 intentionally excludes 401/403/404. Authorization state and target resources change over time, so returning the same response for 24 hours would cause incidents.

We unified the response for concurrent submissions to 409 Conflict. 425 Too Early is also a candidate, but since major HTTP clients’ handling is inconsistent, 409 + Retry-After, which is interpreted reliably, is easier to operate—that’s the reason.

Key Management and Lock Control in Redis

The store side. We atomically acquire the lock 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; // newly acquired → caller proceeds with processing

        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 etc. omitted
}

This is the biggest fix from the previous version. Even for concurrent requests with the same key and same body, while the first one is still processing, the subsequent one is always returned as PROCESSING and does not proceed to chain.doFilter. This reliably stops the situation we originally wanted to prevent: “two were sent simultaneously and two were created.”

state and hash are strictly parsed as JSON and compared field by field, not by string partial matching.

Filter Registration Order

Specify the target URL 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/*");
        // Run after Spring Security's 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 per status is as follows.

  • 2xx is basically cached. This is the original purpose.
  • Only validation-type 4xx like 400/409/422 are cached. The result doesn’t change on retry.
  • 401/403/404 are not cached. Authorization state and resource existence change over time.
  • 5xx are also not cached. Client retries are allowed.

TTL of 24 hours, same as Stripe, is a reasonable starting point. Too long pressures Redis; too short can’t cover the client’s retry window.

Regarding the status when a different body comes in with the same key, this article returns 409 to match Stripe’s approach. The IETF draft also proposes 422 Unprocessable Content, so it’s good to decide in advance which to adopt as part of the API specification.

Verifying Behavior

POST twice with the same key and check the response and 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

Success means the second response exactly matches the first and only one record is in the DB. For concurrent sending, throwing about 20 parallel requests with the same key using k6 lets you verify the lock control behavior. For test automation, using Testcontainers to spin up Redis is solid. For a CRUD API foundation, also see Spring Boot REST API CRUD Tutorial.

Considerations for Production Operation

First, key scope. Scope by user × endpoint and include identifiers in the Redis key like idem:{userId}:{endpoint}:{key}. If global, there’s a risk of accidentally returning another user’s response.

Next, behavior during Redis failure. Fail-closed (stopping the API when Redis is down) prevents double execution but reduces availability. Realistically, divide by business impact: fail-closed for critical operations like payments, fail-open for others.

Finally, logging precautions. Since Idempotency-Key is client-generated, it may contain PII or guessable information. Don’t output it to logs as-is; we recommend hashing or truncating to a few leading characters.

Combining with DB-layer contention control makes it even more robust. If interested, also see Spring Boot JPA Optimistic Locking (@Version) Implementation Guide.

Summary

Idempotency-Key is a low-implementation-cost shield to protect critical POSTs like payments and orders. With Spring Boot, the combination of OncePerRequestFilter and Redis lets you write it more simply than you might think.

The key points: read the body completely with a custom wrapper before hashing; secure the lock with setIfAbsent and reject subsequent requests in processing state with 409 + Retry-After; cache completed responses only for 2xx and validation-type 4xx; TTL is 24 hours. After that, decide scope design and behavior during failures according to your own domain.

Choice of Status Code: 409 vs 422 vs 425

There are multiple options for the status code to return when a different body is sent with the same Idempotency-Key, or when the same key still being processed is resent. Organized in a comparison table:

StatusMain MeaningUse with Idempotency-KeyRecommendation
409 ConflictConflict with resource stateWidely used for body mismatch / resend during processing (Stripe approach)◎ Major HTTP client interpretation is stable
422 Unprocessable ContentSemantically unprocessable requestListed as a candidate in IETF draft for same-key-different-body◯ Selectable if explicit in API spec
425 Too EarlyPremature requestSemantically close to resend during processing△ Client implementation support is unstable

This article prioritizes ease of operation and unifies both body mismatch and processing cases to 409 Conflict + Retry-After header.

Frequently Asked Questions (FAQ)

Q. Who should generate the Idempotency-Key?

The standard is for the client side to generate a unique ID such as UUID v4 and attach one per request. If the server generates it, the same key cannot be reproduced on retry, and idempotency is not established.

Q. What is the appropriate TTL for Idempotency-Key?

24 hours, the same as Stripe, is a safe starting point. It’s a balance point that can cover the client’s retry window (often several minutes to several hours) while keeping Redis memory pressure down.

Q. Should I attach Idempotency-Key to GET as well?

Unnecessary. GET / PUT / DELETE are inherently idempotent by specification, so Idempotency-Key applies only to non-idempotent methods with side effects, such as POST / PATCH.

Q. Aren’t DB unique constraints alone sufficient?

What can be prevented at the DB layer is only “side effects saved to the DB.” Side effects outside the DB, such as payment API calls, email sending, and external Webhook sending, need to be caught at the HTTP layer—this is where Idempotency-Key works.

Q. What’s the difference between Idempotency-Key and distributed locks?

A distributed lock is a mechanism to “control concurrent execution exclusively,” while Idempotency-Key is a mechanism to “reproduce the result of the same operation.” In this article’s implementation, setIfAbsent secures the distributed lock property at the same time, combining both into one Redis key.