The client sends the Idempotency-Key to the server. The server generates/stores the result associated with that key.
Flow
Client | | POST /payments | Idempotency-Key: abc-123-xyz | amount = ₹10,000 ↓ Payment API | | Check: Have I already processed abc-123-xyz? | ├── NO → Process payment | Save key + result | └── YES → Return previous result
First request
The client generates a unique key:
POST /payments Idempotency-Key: abc-123-xyz Content-Type: application/json
{ "amount": 10000, "currency": "INR" }
Server processes it:
abc-123-xyz → PAY-1001 → SUCCESS
The server stores something like:
idempotency_key = abc-123-xyz payment_id = PAY-1001 status = SUCCESS
If network fails
The client doesn't know whether payment succeeded, so it sends the same key:
POST /payments Idempotency-Key: abc-123-xyz
Server checks:
abc-123-xyz exists? ↓ YES ↓ Don't process payment again ↓ Return PAY-1001
So:
Request 1 → abc-123-xyz → Payment PAY-1001 created Request 2 → abc-123-xyz → Existing PAY-1001 returned Request 3 → abc-123-xyz → Existing PAY-1001 returned
Important point
Client generates the key; server is responsible for enforcing it.
Usually the client generates a UUID:
550e8400-e29b-41d4-a716-446655440000
For example, in a frontend:
const idempotencyKey = crypto.randomUUID(); fetch("/payments", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey }, body: JSON.stringify({ amount: 10000, currency: "INR" }) });
The same key must be reused when retrying the same logical payment. A new key means:
new key → server assumes new operation
This is why you should not generate a new key for every retry.
Yes. The backend accepts the key from the client, validates it, checks whether it was already processed, and then returns the payment result.
It does not normally return an empty response just because the key is valid.
Typical Spring Boot flow
Client | | POST /payments | Idempotency-Key: abc-123 | {amount: 10000} ↓ Controller | | 1. Validate key | 2. Check DB ↓ ┌─────────────────────────────┐ │ Does abc-123 already exist? │ └─────────────────────────────┘ | | YES NO | | ↓ ↓ Return saved Process payment response | ↓ Save key + result | ↓ Return result
Example Controller
@PostMapping("/payments") public ResponseEntity<PaymentResponse> createPayment( @RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request) { // 1. Validate key if (idempotencyKey == null || idempotencyKey.isBlank()) { return ResponseEntity.badRequest().build(); } // 2. Check whether this key was already processed Optional<IdempotencyRecord> existing = idempotencyRepository.findByKey(idempotencyKey); if (existing.isPresent()) { // Retry → return SAME result return ResponseEntity.ok(existing.get().getPaymentResponse()); } // 3. First request → process payment PaymentResponse response = paymentService.processPayment(request); // 4. Store key + result idempotencyRepository.save( new IdempotencyRecord( idempotencyKey, response ) ); // 5. Return result return ResponseEntity.ok(response); }
First request
Client sends:
POST /payments Idempotency-Key: abc-123
Backend:
abc-123 exists? → NO ↓ Process payment ↓ PAY-1001 created ↓ Save: abc-123 → PAY-1001 SUCCESS ↓ Return:
{ "paymentId": "PAY-1001", "status": "SUCCESS" }
Retry because client didn't receive response
Client sends the same key:
POST /payments Idempotency-Key: abc-123
Backend:
abc-123 exists? → YES ↓ DO NOT process payment again ↓ Return stored response
{ "paymentId": "PAY-1001", "status": "SUCCESS" }
So the client receives the same payment result.
What if the key is valid but payment fails?
Suppose:
abc-123 → Payment processing failed
You have a design choice.
For many payment systems, you store the final result associated with the key, including an appropriate failure outcome, so retries with the same key don't accidentally create a different operation.
For example:
{ "paymentId": "PAY-1001", "status": "FAILED", "reason": "Insufficient funds" }
A retry using abc-123 can return that same result.
One very important backend detail
You need a unique constraint on the idempotency key:
CREATE TABLE idempotency_keys ( id BIGINT PRIMARY KEY, idempotency_key VARCHAR(255) UNIQUE, payment_id VARCHAR(100), status VARCHAR(50), response JSON );
The UNIQUE constraint is important because two requests could arrive at almost exactly the same time:
Request A ──→ abc-123 ──→ check DB → not found Request B ──→ abc-123 ──→ check DB → not found
Without proper concurrency handling, both could process the payment.
That's where real-world idempotency becomes more interesting: you need atomic insert / unique constraint + transaction/locking strategy, not just an if (exists) check.
No. @Idempotent is not a built-in Spring Boot annotation.
If you see code like:
@Idempotent @PostMapping("/payments") public PaymentResponse createPayment(...) { ... }
then @Idempotent is almost certainly:
- a custom annotation created by the application, or
- provided by a third-party library.
Spring Boot itself does not provide a general-purpose @Idempotent annotation for REST APIs.
In Spring Boot
You could create your own:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { }
Then use it:
@Idempotent @PostMapping("/payments") public PaymentResponse createPayment(...) { // payment logic }
But the annotation alone does nothing.
You would need something such as Spring AOP:
@Idempotent ↓ Spring AOP intercepts method ↓ Read Idempotency-Key ↓ Check Redis/DB ↓ Already exists? YES → return previous response NO → execute method ↓ save response
So if you see @Idempotent in a project, don't assume it's Spring Boot. Check its import:
import ....Idempotent;
That import will tell you exactly where it comes from.
Yes. Let's build a real Spring Boot AOP-based idempotency implementation where:
- Client sends
Idempotency-Key - AOP intercepts methods marked
@Idempotent - Request/key is validated
- Redis checks whether the key was already processed
- If already processed → return the same stored response
- If not → execute the actual business method
- Store the response in Redis
- Return the response
For a real application, I would use Redis, because it is fast and supports atomic operations.
1. Architecture
Client | | POST /payments | Idempotency-Key: abc-123 ↓ ┌──────────────────────┐ │ Spring Controller │ │ @Idempotent │ └──────────┬───────────┘ | ↓ ┌──────────────────────┐ │ AOP Aspect │ │ │ │ Validate key │ │ Check Redis │ └──────────┬───────────┘ | ┌────────┴────────┐ ↓ ↓ Key exists Key doesn't exist | | ↓ ↓ Return stored Execute method response | ↓ Payment Service | ↓ Database | ↓ PaymentResponse | ↓ Store in Redis | ↓ Return response
2. Maven dependencies
For Spring Boot 3/4 style applications, conceptually you need:
<dependencies> <!-- Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- AOP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>
3. Create @Idempotent
This is our custom annotation.
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** * How long the response should remain cached. */ long ttlSeconds() default 86400; }
Here:
@Idempotent
means:
"Apply idempotency handling to this API."
4. Request DTO
public record PaymentRequest( BigDecimal amount, String currency, String customerId ) { }
Example request:
{ "amount": 10000, "currency": "INR", "customerId": "CUST-101" }
5. Response DTO
public record PaymentResponse( String paymentId, String status, BigDecimal amount ) { }
Example:
{ "paymentId": "PAY-1001", "status": "SUCCESS", "amount": 10000 }
6. Redis configuration
spring: data: redis: host: localhost port: 6379
Start Redis locally:
docker run --name redis \ -p 6379:6379 \ -d redis
7. Idempotency response object
We need to store more than just the response.
public record IdempotencyRecord( String status, String response ) { }
For example Redis could contain:
idempotency:abc-123
with:
{ "status": "COMPLETED", "response": "{\"paymentId\":\"PAY-1001\",\"status\":\"SUCCESS\"}" }
8. Redis service
@Service public class IdempotencyStore { private final RedisTemplate<String, String> redisTemplate; public IdempotencyStore(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; } public String get(String key) { return redisTemplate.opsForValue().get(key); } public boolean createProcessingKey( String key, long ttlSeconds) { Boolean created = redisTemplate .opsForValue() .setIfAbsent( key, "PROCESSING", Duration.ofSeconds(ttlSeconds) ); return Boolean.TRUE.equals(created); } public void saveResponse( String key, String response, long ttlSeconds) { redisTemplate.opsForValue().set( key, response, Duration.ofSeconds(ttlSeconds) ); } }
The important part is:
setIfAbsent(...)
This is effectively:
SET key only if it doesn't already exist
That is important for concurrent requests.
9. Now the important part — AOP
@Aspect @Component public class IdempotencyAspect { private final IdempotencyStore store; private final ObjectMapper objectMapper; public IdempotencyAspect( IdempotencyStore store, ObjectMapper objectMapper) { this.store = store; this.objectMapper = objectMapper; } @Around("@annotation(idempotent)") public Object handleIdempotency( ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder .currentRequestAttributes()) .getRequest(); String idempotencyKey = request.getHeader("Idempotency-Key"); // 1. Validate key if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new IllegalArgumentException( "Idempotency-Key header is required"); } // 2. Build Redis key String redisKey = "idempotency:" + idempotencyKey; // 3. Check existing key String existing = store.get(redisKey); if (existing != null) { // Request already processed if (!existing.equals("PROCESSING")) { return objectMapper.readValue( existing, PaymentResponse.class ); } // Another request is currently processing throw new IllegalStateException( "Request is already being processed"); } // 4. Atomically create processing key boolean acquired = store.createProcessingKey( redisKey, idempotent.ttlSeconds() ); if (!acquired) { throw new IllegalStateException( "Request is already being processed"); } try { // 5. Execute actual controller/service method Object response = joinPoint.proceed(); // 6. Serialize response String responseJson = objectMapper.writeValueAsString(response); // 7. Store final response store.saveResponse( redisKey, responseJson, idempotent.ttlSeconds() ); // 8. Return response return response; } catch (Exception e) { /* * Important: * Remove PROCESSING key so that a retry * can attempt the operation again. */ redisTemplate.delete(redisKey); throw e; } } }
There is one correction here: the aspect would need the RedisTemplate if it directly deletes the key. Better design is to put delete() inside IdempotencyStore:
public void delete(String key) { redisTemplate.delete(key); }
Then:
catch (Exception e) { store.delete(redisKey); throw e; }
10. Controller
Now the controller becomes very clean.
@RestController @RequestMapping("/payments") public class PaymentController { private final PaymentService paymentService; public PaymentController( PaymentService paymentService) { this.paymentService = paymentService; } @Idempotent @PostMapping public PaymentResponse createPayment( @RequestBody PaymentRequest request) { return paymentService.createPayment(request); } }
The developer doesn't need to write:
if (keyExists) ...
inside every controller.
AOP handles it.
11. Payment service
@Service public class PaymentService { public PaymentResponse createPayment( PaymentRequest request) { System.out.println("PROCESSING PAYMENT"); // Call payment provider // Save payment to database // etc. return new PaymentResponse( "PAY-1001", "SUCCESS", request.amount() ); } }
12. First request
Client:
POST /payments Idempotency-Key: abc-123 Content-Type: application/json
{ "amount": 10000, "currency": "INR", "customerId": "CUST-101" }
AOP:
abc-123 ↓ Redis lookup ↓ NOT FOUND ↓ SETNX abc-123 = PROCESSING ↓ joinPoint.proceed() ↓ PaymentService ↓ PAY-1001 SUCCESS ↓ Store response
Redis:
idempotency:abc-123 { "paymentId": "PAY-1001", "status": "SUCCESS", "amount": 10000 }
Client gets:
{ "paymentId": "PAY-1001", "status": "SUCCESS", "amount": 10000 }
13. Client retries
Suppose response was lost:
Backend → Payment successful Backend → Response X Network failure
Client doesn't know what happened.
It retries:
POST /payments Idempotency-Key: abc-123
AOP:
abc-123 ↓ Redis ↓ FOUND ↓ Return stored response
Payment service is not called.
Client gets:
{ "paymentId": "PAY-1001", "status": "SUCCESS", "amount": 10000 }
Therefore:
Payment created: 1 API requests: 2
14. What if the key is missing?
Client:
POST /payments
No:
Idempotency-Key
AOP rejects it:
400 Bad Request
Response could be:
{ "error": "Idempotency-Key header is required" }
15. What if the key is already processing?
This situation is important.
Imagine two requests arrive simultaneously:
Request A ──→ abc-123 Request B ──→ abc-123
A:
SETNX abc-123 ↓ SUCCESS ↓ PROCESSING
B:
SETNX abc-123 ↓ FAIL ↓ Already processing
So B should not execute the payment.
Depending on your API design, you might return:
409 Conflict
or:
409 Request already in progress
16. One more VERY important validation
You shouldn't only validate:
Idempotency-Key exists
You should also make sure the same key isn't reused for a different request.
For example:
First request
Idempotency-Key: abc-123
{ "amount": 10000 }
Then somebody sends:
Idempotency-Key: abc-123
{ "amount": 50000 }
That's suspicious.
The server should detect:
Same key + Different request ↓ 400 Bad Request
A common solution is to store a request fingerprint/hash:
idempotency_key request_hash status response
For example:
abc-123 SHA256(request) SUCCESS PAY-1001 response
On retry:
abc-123 SHA256(new request)
If hashes don't match:
409/400 Idempotency key reused with different request
17. Production architecture I'd recommend
For your Spring Boot/RAG/backend experience, understand this architecture:
Client | | Idempotency-Key ↓ API Gateway | ↓ Spring Boot API | @Idempotent | ↓ AOP Aspect | ┌─────────┴─────────┐ ↓ ↓ Redis Database | | | Business data | ├── PROCESSING | └── COMPLETED + response
And the key production concepts are:
Request validation
Key exists? Key format valid? Same request hash?
Concurrency
SETNX / atomic operation
Response storage
key → status + response
Failure handling
PROCESSING → failure ↓ remove/recover key
Expiration
TTL → don't keep idempotency records forever
Database consistency
For something like payment, Redis idempotency alone isn't enough. The payment database/provider operation and idempotency record need a carefully designed consistency strategy. Otherwise you can still have edge cases around crashes between "payment succeeded" and "response stored."
So the simple AOP implementation is excellent for understanding the concept, but payments require a stronger production design than just caching the controller response in Redis.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* How long the response should remain cached.
*/
long ttlSeconds() default 86400;
}
vs
@Aspect
@Component
public class IdempotencyAspect {They are two completely different things. This distinction is very important when learning Spring AOP.
Think of it as:
@Idempotent annotation ↓ LABEL ↓ "Which methods should have idempotency?" + @Aspect class ↓ LOGIC ↓ "What should happen when that method is called?"
1. @Idempotent — custom annotation
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { long ttlSeconds() default 86400; }
This does not implement idempotency.
It simply creates your own annotation that you can put on a method:
@Idempotent @PostMapping("/payments") public PaymentResponse createPayment() { ... }
Think of it like putting a sticker on the method:
createPayment() ↑ @Idempotent
The sticker says:
"This method needs idempotency handling."
But the sticker itself doesn't do anything.
2. @Aspect — actual logic
@Aspect @Component public class IdempotencyAspect {
This class contains the actual idempotency logic.
For example:
@Around("@annotation(idempotent)") public Object handleIdempotency( ProceedingJoinPoint joinPoint, Idempotent idempotent) { // Read Idempotency-Key // Check Redis // If exists → return previous response // If doesn't exist → execute method // Save response }
The important part is:
@Around("@annotation(idempotent)")
It means:
"Whenever a method has
@Idempotent, intercept that method."
3. How they work together
Suppose you have:
@Idempotent @PostMapping("/payments") public PaymentResponse createPayment( @RequestBody PaymentRequest request) { return paymentService.createPayment(request); }
Spring sees:
createPayment() ↑ @Idempotent | ↓ IdempotencyAspect finds it | ↓ handleIdempotency() | ┌───────────┴───────────┐ ↓ ↓ Key exists? Key doesn't exist ↓ ↓ Return saved response Execute method ↓ createPayment() ↓ Save response ↓ Return response
4. Very simple analogy
Imagine a security guard.
Annotation
@Idempotent
is like putting a sign on a door:
┌───────────────────────┐ │ PAYMENT API │ │ │ │ 🔒 Idempotent │ └───────────────────────┘
It tells the system:
"This door needs special handling."
Aspect
@Aspect public class IdempotencyAspect
is the security guard standing outside the door.
The guard actually checks:
Who is coming? ↓ What's the key? ↓ Have I seen this key? ↓ YES → give previous response NO → allow request through
5. Why not put everything inside @Idempotent?
You might wonder:
Why don't we just put the logic inside the annotation?
Because an annotation is primarily metadata.
This:
public @interface Idempotent { long ttlSeconds() default 86400; }
basically says:
Name: Idempotent Configuration: ttlSeconds = 86400
It doesn't contain the business behavior.
The Aspect contains the behavior.
6. The ttlSeconds connects them
This part is particularly useful:
@Idempotent(ttlSeconds = 3600)
The annotation can provide configuration to the Aspect.
The Aspect receives it here:
@Around("@annotation(idempotent)") public Object handleIdempotency( ProceedingJoinPoint joinPoint, Idempotent idempotent) {
Then:
idempotent.ttlSeconds()
returns:
3600
So:
@Idempotent(ttlSeconds = 3600)
means:
Store the idempotency result for 1 hour.
While:
@Idempotent
means:
Use the default 86400 seconds = 24 hours.
7. Complete relationship
// 1. Define the annotation @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { long ttlSeconds() default 86400; }
↓
// 2. Implement the behavior @Aspect @Component public class IdempotencyAspect { @Around("@annotation(idempotent)") public Object handle( ProceedingJoinPoint joinPoint, Idempotent idempotent) { long ttl = idempotent.ttlSeconds(); // idempotency logic... return ...; } }
↓
// 3. Developer uses it @Idempotent(ttlSeconds = 3600) @PostMapping("/payments") public PaymentResponse createPayment( @RequestBody PaymentRequest request) { return paymentService.createPayment(request); }
So the relationship is:
┌────────────────────┐ │ @Idempotent │ │ │ │ Metadata/config │ │ ttlSeconds = 3600 │ └─────────┬──────────┘ │ │ Aspect detects annotation ↓ ┌────────────────────┐ │ IdempotencyAspect │ │ │ │ Actual logic │ │ │ │ Read key │ │ Validate │ │ Check Redis │ │ Execute method │ │ Store response │ └────────────────────┘
One-line interview answer
@Idempotentis a custom marker/configuration annotation that identifies which methods require idempotency, while@Aspectcontains the cross-cutting logic that intercepts those methods and actually implements idempotency.