Idempotent Keys Explained: How Stripe's Approach Prevents Duplicate Requests and Builds Trust
A developer's guide to understanding idempotent keys, why they matter beyond payments, and how to implement them in Spring Boot for safer, more reliable systems.
Dear readers,
Let’s be honest, most of us have faced that moment of frustration when technology doesn’t behave the way we expect. Maybe you clicked “Pay” on a checkout page, the internet blinked, and you weren’t sure whether you should hit the button again. Or maybe you submitted a form twice because the first time seemed to fail, only to realize later that your action got recorded multiple times.
These little glitches don’t just annoy us as users; they can create big headaches for the systems and teams behind the scenes. Duplicate payments, repeated orders, multiple job submissions, these are not just bugs, they’re trust-breakers. When users lose confidence that your system will “do the right thing”, they start doubting whether they should use it at all.
This is where idempotent keys, a concept Stripe brought into the spotlight, come in. And trust me, it’s one of those rare engineering ideas that’s both simple and deeply impactful.
What Stripe Did with Idempotent Keys
Stripe, being a payments company, had a problem to solve:
Customers might refresh or retry during checkout.
Network requests might time out and be retried by clients automatically.
Without safeguards, this could result in someone being charged twice for the same transaction.
Their solution was beautifully straightforward:
Every request sent to Stripe can include a unique idempotent key, essentially a unique identifier (like a UUID).
Here’s how it works:
The first time Stripe sees that key, it processes the request and stores the result.
If the same key shows up again, Stripe doesn’t re-run the process, it simply returns the same result it gave the first time.
The magic? To the customer, the action always feels safe and predictable. No matter how many times they click “Pay”, they’ll only ever be charged once.
Why This Matters Beyond Payments
At first glance, idempotent keys sound like a “payments thing”. But if you take a closer look, the underlying principle applies everywhere:
APIs & Microservices
When your services call each other, retries are inevitable. Idempotency ensures retries don’t create duplicate records or trigger duplicate jobs.Event-Driven Systems
Events often get delivered more than once. Without idempotency, you risk creating duplicate entities or processing the same task twice.Batch Jobs & Uploads
Imagine uploading a large file, only for the system to fail midway. With idempotency, rerunning the job won’t duplicate data, it just resumes gracefully.User Actions
From submitting feedback forms to booking tickets, idempotency makes sure each “intention” from the user gets captured once, not multiple times.
In other words, anywhere retries or duplicate requests can happen, idempotency helps you stay safe.
Benefits of Idempotent Keys
Reliability in the face of retries
Your system can handle the messy realities of networks and user actions without breaking trust.Better user experience
Users feel reassured knowing they won’t be double-billed, double-registered, or double-subscribed.Operational safety
Engineers and support teams spend less time cleaning up duplicate data or refunding accidental charges.Simplicity in design
Instead of inventing complex duplicate-handling logic, you get a clean, repeatable pattern.Traceability
Each request is tied to a key, making it easier to debug, audit, and reason about the system’s behaviour.
How to Implement Idempotent Keys in Spring Boot
Let’s make this concrete with a Java Spring Boot example. We’ll design a reusable solution using annotations so that any endpoint can become idempotent just by adding @Idempotent.
Don’t feel like reading the full breakdown? Explore the code directly here.
Step 1. Create the Annotation
We’ll mark methods that should support idempotency.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// Optional: custom expiry time in seconds (default = 24 hours)
long expirySeconds() default 86400;
}Step 2. Create the Aspect
This Aspect will:
Check for the
Idempotency-Keyheader.If the key exists in Redis, return cached response.
If not, process request, save response, and return it.
@Aspect
@Component
public class IdempotencyAspect {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
public IdempotencyAspect(RedisTemplate<String, String> redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
@Around("@annotation(idempotent)")
public Object handleIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
// Not in a web request context, proceed normally
return joinPoint.proceed();
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String idempotencyKey = request.getHeader("Idempotency-Key");
if (idempotencyKey == null || idempotencyKey.isEmpty()) {
if (response != null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing Idempotency-Key header");
}
return null;
}
// Add the idempotency key to response headers for visibility
if (response != null) {
response.setHeader("X-Idempotency-Key", idempotencyKey);
}
// Check if we've seen this key
String cachedResponse = redisTemplate.opsForValue().get(idempotencyKey);
if (cachedResponse != null) {
// Cache HIT - return cached response
if (response != null) {
response.setHeader("X-Idempotency-Cache-Status", "HIT");
}
// For cached responses, we always return a ResponseEntity with the cached body
// since our controller methods return ResponseEntity
try {
Object responseBody = objectMapper.readValue(cachedResponse, Object.class);
return ResponseEntity.ok(responseBody);
} catch (Exception e) {
// If deserialization fails, proceed with normal execution
// This ensures the system remains robust
System.err.println("Failed to deserialize cached response: " + e.getMessage());
}
}
// Cache MISS - process the request and cache the response
if (response != null) {
response.setHeader("X-Idempotency-Cache-Status", "MISS");
}
Object result = joinPoint.proceed();
// Cache the response if it's successful
if (result != null) {
try {
String responseBody;
if (result instanceof ResponseEntity<?> responseEntity) {
// Extract the body from ResponseEntity and serialize it
responseBody = objectMapper.writeValueAsString(responseEntity.getBody());
} else {
responseBody = objectMapper.writeValueAsString(result);
}
long expirySeconds = idempotent.expirySeconds();
redisTemplate.opsForValue().set(idempotencyKey, responseBody, expirySeconds, TimeUnit.SECONDS);
} catch (Exception e) {
// Log the exception but don't fail the request
System.err.println("Failed to cache idempotent response: " + e.getMessage());
}
}
return result;
}
}
Step 3. Use It in a Controller
Now, simply annotate your endpoint:
@RestController
@RequestMapping("/api")
public class SampleController {
@PostMapping("/orders")
@Idempotent(expirySeconds = 3600) // 1 hour custom expiry
public ResponseEntity<Map<String, Object>> createOrder(@RequestBody Map<String, Object> orderRequest) {
// Simulate order creation
Map<String, Object> response = new HashMap<>();
response.put("orderId", UUID.randomUUID().toString());
response.put("status", "created");
response.put("timestamp", LocalDateTime.now().toString());
response.put("customerData", orderRequest);
return ResponseEntity.ok(response);
}
}Step 4. Client Example
Clients must send the header:
First request: processes and creates an order.
curl -v -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-H "Idempotency-Key: fixed-test-123" \
-d '{"customerId": "customer-1", "items": [{"id": "item-1", "quantity": 2}]}'
> Content-Type: application/json
> Idempotency-Key: fixed-test-123
> Content-Length: 72
>
* upload completely sent off: 72 bytes
< HTTP/1.1 200
< X-Idempotency-Key: fixed-test-123
< X-Idempotency-Cache-Status: MISS
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 26 Aug 2025 16:53:22 GMT
<
* Connection #0 to host localhost left intact
{"orderId":"c9431b5d-2a01-48b4-b788-442a82f5827c","customerData":{"customerId":"customer-1","items":[{"id":"
item-1","quantity":2}]},"status":"created","timestamp":"2025-08-26T16:53:22.806485375"}Second request with same key: returns the exact same response.
curl -v -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-H "Idempotency-Key: fixed-test-123" \
-d '{"customerId": "customer-1", "items": [{"id": "item-1", "quantity": 2}]}'
> Content-Type: application/json
> Idempotency-Key: fixed-test-123
> Content-Length: 72
>
* upload completely sent off: 72 bytes
< HTTP/1.1 200
< X-Idempotency-Key: fixed-test-123
< X-Idempotency-Cache-Status: HIT
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 26 Aug 2025 16:53:30 GMT
<
* Connection #0 to host localhost left intact
{"orderId":"c9431b5d-2a01-48b4-b788-442a82f5827c","customerData":{"customerId":"customer-1","items":[{"id":"
item-1","quantity":2}]},"status":"created","timestamp":"2025-08-26T16:53:22.806485375"}Dive into the full code example on Github.
Things to Keep in Mind
Expiration
Don’t keep keys forever. Set a time limit that makes sense (e.g., a day for payments, longer for data uploads).Scope
Define what the key represents. Is it for a whole order? A single file upload? A single API call?Atomicity
Make sure storing and checking the key happen atomically to avoid race conditions.Consistency
Be clear about which parts of the request are idempotent. For example, in payments, the amount and currency should remain constant for the same key.
Closing Thoughts
Idempotent keys are one of those rare engineering practices that quietly do wonders. Stripe made them famous by solving a very human problem: the fear of being charged twice. But the principle is universal, make actions safe, predictable, and trustworthy.
Whether you’re running a payment system, a booking platform, or just an API that retries requests, adopting idempotent keys means you’re building resilience into your foundation. And resilience, at the end of the day, is what separates systems people merely use from systems people deeply trust.





Great Read !