When building payment or order APIs, you can’t avoid the trouble of “the user double-clicked and two identical orders went through” or “a network retry caused a double charge.” The classic approach to preventing this is the Idempotency-Key header pattern, familiar from services like Stripe.

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

Why REST APIs Need Idempotency

Duplicate execution happens more often than you’d think. Users mash the submit button, mobile connections drop momentarily and clients auto-retry, load balancers resend after a timeout — the causes vary.

GET/PUT/DELETE are idempotent by specification, so they’re not an issue. The problem is POST. For operations with side effects — payments, orders, money transfers, email sending — duplicate execution leads directly to financial and operational damage.

Sometimes a unique constraint on the DB side is enough, but for operations whose side effects extend 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 via the Idempotency-Key header, and the server stores the result of the first processing (status + body) associated with that key. If a retry comes in with the same key, the stored response is returned as-is.

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

Overall Architecture

In a Spring Boot application, sandwiching the request and response with a Filter is the easiest approach. Since you need to store the response body, the combination of OncePerRequestFilter + ContentCachingResponseWrapper is more straightforward than using an Interceptor. For the difference between Filters and Interceptors, see Spring Boot Filter vs Interceptor: Differences and Usage.

We’ll use Redis as the store. The deciding factors are: TTL works automatically, it provides a consistent store in a distributed environment, and setIfAbsent makes exclusive control easy.

Project Setup

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 Redis setup, reading Spring Boot and Redis Integration Guide first will make things smoother.

A Wrapper That Reads the Request Body Completely

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

So we’ll prepare a wrapper that reads the stream completely 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));
    }
}

Now we can use it for hash computation inside the Filter, and the handler can read the body without issues.

Implementing the OncePerRequestFilter

We’ll target only POST and PATCH and pass everything else through. We also lightly validate the key value at the entrance 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();
    }

    /** Policy: cache only 2xx and validation-type 4xx (400/409/422).
     *  Exclude 401/403/404 since their state changes over time, and 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 deliberately excludes 401/403/404. Since authorization state and the target resource change over time, returning the same response for 24 hours would cause incidents.

We’ve unified concurrent-submission responses under 409 Conflict. 425 Too Early is also a candidate, but since handling among major HTTP clients is inconsistent, 409 + Retry-After, which is interpreted reliably, is easier to operate.

Key Management and Lock Control in Redis

Now for the store side. We acquire a lock atomically with setIfAbsent, and if a value already exists, we strictly parse the 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 correction from the previous version. Even for concurrent requests with the same key and same body, while the first is still processing, subsequent ones are always returned as PROCESSING and never advance to chain.doFilter. This reliably stops the situation we wanted to prevent in the first place — “two were sent simultaneously and two records were created.”

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

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/*");
        // Run after Spring Security's FilterChainProxy (DEFAULT_FILTER_ORDER=-100)
        bean.setOrder(0);
        return bean;
    }
}

If placed 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: basically cache. This is the original purpose.
  • 400/409/422: cache only validation-type errors. The result won’t change on retry.
  • 401/403/404: do not cache. Authorization state and resource existence change over time.
  • 5xx: do not cache. Allow client retries.

TTL is reasonably set to 24 hours as a starting point, matching Stripe. Too long pressures Redis; too short fails to cover the client’s retry window.

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

Verification

POST twice with the same key and check the responses 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, firing about 20 parallel requests with the same key using k6 will let you observe the lock control in action. For test automation, spinning up Redis with Testcontainers is a solid approach. For the foundations of a CRUD API, also see Spring Boot REST API CRUD Tutorial.

Notes for Production Operation

First, key scope. Scope by user × endpoint, and include identifiers in the Redis key like idem:{userId}:{endpoint}:{key}. Making it global risks accidentally returning another user’s response.

Next, behavior during Redis failures. Fail-closed (stopping the API when Redis is down) prevents duplicate execution but reduces availability. A realistic approach is to use fail-closed for critical operations like payments and fail-open elsewhere, based on business impact.

Finally, logging considerations. Since Idempotency-Key is generated by the client, it may contain PII or guessable information. Avoid logging it as-is; hashing or truncating to the first few characters is recommended.

Combining this with DB-layer concurrency 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 for protecting critical POSTs like payments and orders. With Spring Boot, the combination of OncePerRequestFilter and Redis lets you write it more simply than you might expect.

The key points are: read the body completely with a custom wrapper before hashing, secure a lock with setIfAbsent and reject subsequent requests in processing state with 409 + Retry-After, cache completed responses only for 2xx and validation-type 4xx, and set TTL to 24 hours. From there, design the scope and failure behavior to fit your own domain.