When building APIs, especially for payments or operations that modify state, you want to avoid executing the same request multiple times if the client retries. Stripe popularized the concept of idempotency keys: clients attach a unique key to a request, and the server ensures the same request isn’t processed twice.

In this post, we’ll walk through how to bring that Stripe-style idempotency pattern into a NestJS application using an Interceptor.

Why Idempotency Matters

Imagine your user submits a payment form. The frontend sends the request, but the network times out. The user refreshes and clicks “Pay” again. Without idempotency, you risk double-charging the card.

With idempotency:

  • The client sends a unique key (e.g., Idempotency-Key header).
  • The server stores the result of the first request.
  • Subsequent requests with the same key return the cached result instead of executing the logic again.

This prevents double-processing and gives a consistent API contract.

High-Level Design

We’ll build a NestJS interceptor that:

  1. Checks for the Idempotency-Key header.
  2. Looks up whether we’ve already processed a request with this key.
  3. If yes → return the stored response immediately.
  4. If no → process the request, store the response (or error), and return it.

For storage, you can use:

  • In-Memory Cache (Redis/Memcached) → recommended in production, especially for distributed systems.
  • Database → if you want persistence across services.
  • NestJS In-Memory CacheModule → fine for demos or single-instance apps.

Implementation Step-by-Step

1. Set up a Cache (Redis recommended)

Install Redis and connect it with cache-manager-redis-store:

npm install cache-manager ioredis cache-manager-redis-store

Configure it in your AppModule:

import { CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';

@Module({
  imports: [
    CacheModule.registerAsync({
      useFactory: async () => ({
        store: redisStore,
        host: 'localhost',
        port: 6379,
        ttl: 60 * 5, // cache entries expire after 5 minutes
      }),
    }),
  ],
})
export class AppModule {}

Explanation:

  • We register a global CacheModule using Redis as the backend.
  • Every cached entry will expire after 5 minutes (ttl). You can adjust this depending on your business rules.
  • Redis ensures your idempotency works across multiple app instances (important if you’re running in Kubernetes or load balanced environments).

2. Create the Idempotency Interceptor

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  BadRequestException,
  Inject,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Observable, from } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest();
    const idempotencyKey = request.headers['idempotency-key'];

    if (!idempotencyKey) {
      throw new BadRequestException('Idempotency-Key header is required');
    }

    // Check if cached response exists
    const cachedResponse = await this.cacheManager.get(idempotencyKey);
    if (cachedResponse) {
      return from([cachedResponse]);
    }

    // Otherwise, process the request and cache result
    return next.handle().pipe(
      tap(async (response) => {
        await this.cacheManager.set(idempotencyKey, response, { ttl: 60 * 5 });
      }),
    );
  }
}

Explanation:

  • ExecutionContext lets us access the request. We pull the Idempotency-Key header from it.
  • If the header is missing, we immediately throw a 400 Bad Request.
  • this.cacheManager.get(idempotencyKey) checks if we’ve seen this key before:
    • If yes → return the cached response wrapped in an observable (from([cachedResponse])).
    • If no → let the request through with next.handle(). Once the controller returns a result, the tap() operator stores it in Redis under that key.
  • Next time the same key comes in, the interceptor will skip the controller and return the cached value.

3. Apply the Interceptor

At the controller level:

import { Controller, Post, UseInterceptors, Body } from '@nestjs/common';
import { IdempotencyInterceptor } from './idempotency.interceptor';

@Controller('payments')
export class PaymentsController {
  @Post()
  @UseInterceptors(IdempotencyInterceptor)
  async createPayment(@Body() body: any) {
    // Your business logic (e.g., charge user, save record)
    return { status: 'success', chargeId: 'ch_12345' };
  }
}

Explanation:

  • We decorate the POST /payments endpoint with @UseInterceptors(IdempotencyInterceptor).
  • The interceptor wraps the controller:
    • First call → executes createPayment, caches the result.
    • Second call with the same key → returns the cached { status: 'success', chargeId: 'ch_12345' } immediately.

Now, if the client retries with the same Idempotency-Key, the exact same JSON response is returned.

Step 4: Test the Behavior

# First request
curl -X POST http://localhost:3000/payments \
  -H "Idempotency-Key: test-123"

# => { "status": "success", "chargeId": "ch_12345" }

# Retry same request
curl -X POST http://localhost:3000/payments \
  -H "Idempotency-Key: test-123"

# => same response instantly, controller never runs again

Additional Considerations

  • Error responses: You can also cache errors, ensuring clients don’t retry a failing request endlessly.
  • Expiration: Stripe defaults to 24 hours. Adjust ttl to your use case.
  • Scoping: For high-security APIs, scope idempotency keys per user. Example: userId:idempotencyKey.
  • Security: Don’t allow unbounded key growth. Enforce cleanup or TTLs.
  • Replay protection: Idempotency is not just about avoiding duplication but also ensuring consistent results on retries.

Conclusion

By wrapping your business logic inside an IdempotencyInterceptor, you bring Stripe’s robust retry-safety model into your NestJS apps.

This small addition dramatically improves the reliability of endpoints where retries are likely — payments, account creations, batch jobs, etc.

Your clients will thank you, and you’ll sleep better knowing duplicate requests won’t wreak havoc.

Sign up to receive updates on new content & exclusive offers

We don’t spam! Cancel anytime.