Article7 min read

Preventing Duplicate Payments with Idempotency in NestJS

Prevent double payment in payment service using technique called idempotency

p
pandusudo
Share:
Preventing Duplicate Payments with Idempotency in NestJS

Common issue in payment service

One of the most common and dangerous problems in payment systems is duplicate transactions.

Imagine this scenario:

  1. A user clicks “Pay Now”.
  2. The request is sent to the server.
  3. The payment succeeds, but the network response is slow.
  4. The user thinks the request failed and clicks “Pay Now” again.

Without proper safeguards, the system may process two payments instead of one. This is where idempotency becomes essential.

What is idempotency

In system design, idempotency means performing the same operation multiple times produces the same result.

For example:

POST /payments
POST /payments

If the same request is sent multiple times, the server should only process it once. Instead of charging the user twice, the server should return the same response from the first request. This is usually implemented using an Idempotency Key.

An Idempotency Key is a unique identifier generated by the client and sent with a request.

Example request:

POST /payments
Idempotency-Key: 8f3c6b20-21e7-4a8e-bf28-72c6f8c9e111
POST /payments
Idempotency-Key: 8f3c6b20-21e7-4a8e-bf28-72c6f8c9e111

If the server receives the same key again, it does not process the payment again. Instead, it returns the previous response. This ensures the operation is safe to retry.

Many payment platforms use this pattern, including:

  • Stripe
  • PayPal
  • Square

Architecture Overview

A typical idempotency workflow looks like this: Idempotency workflowIdempotency workflow

The key idea is to store the result of the first request. If the same request arrives again, return the stored result instead of executing the operation again.

Implementing Idempotency in NestJS

Now that we understand the concept of idempotency, let's implement it in a real backend using NestJS. The goal is simple:

If the same request with the same idempotency key arrives multiple times, the server should execute the operation only once and return the same response for subsequent requests.

To achieve this, we will build a small system consisting of:

  1. A database table to store idempotency records
  2. A service layer to interact with the stored keys
  3. A NestJS interceptor to handle the idempotency logic
  4. A controller endpoint to demonstrate the behavior

This architecture allows idempotency to be reused across multiple endpoints without polluting business logic.

Step 1 — Create an Idempotency Table

The first thing we need is persistent storage for idempotency keys. When a request with an idempotency key is processed, we want to store:

  • the idempotency key
  • the response body
  • the HTTP status code
  • timestamp

This allows the server to replay the exact same response if the same key appears again. Example SQL schema:

CREATE TABLE idempotency_keys (
id SERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
response JSONB,
status_code INT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE idempotency_keys (
id SERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
response JSONB,
status_code INT,
created_at TIMESTAMP DEFAULT NOW()
);

Step 2 — Create an Idempotency Service

Next, we create a service layer responsible for interacting with the database. Example implementation:

@Injectable()
export class IdempotencyService {
constructor(
@InjectRepository(IdempotencyEntity)
private repo: Repository<IdempotencyEntity>,
) {}

async find(key: string) {
return this.repo.findOne({
where: { key }
});
}

async save(key: string, response: any, statusCode: number) {
const record = this.repo.create({
key,
response,
statusCode
});

return this.repo.save(record);
}
}
@Injectable()
export class IdempotencyService {
constructor(
@InjectRepository(IdempotencyEntity)
private repo: Repository<IdempotencyEntity>,
) {}

async find(key: string) {
return this.repo.findOne({
where: { key }
});
}

async save(key: string, response: any, statusCode: number) {
const record = this.repo.create({
key,
response,
statusCode
});

return this.repo.save(record);
}
}

The find method checks if a request with the same key has already been processed. Example usage:

const existing = await idempotencyService.find(key)
const existing = await idempotencyService.find(key)

If a record exists, we know that this operation was already executed before.

The save method stores the result of the request after it has successfully completed. It saves:

  • the idempotency key
  • response body
  • status code

This is important because the next request should return the exact same response, including HTTP status.

Step 3 — Create an Idempotency Interceptor

Now comes the core of the implementation. In NestJS, interceptors allow us to run logic:

  • before the request handler executes
  • after the response is generated

This makes them perfect for implementing idempotency. We want the interceptor to:

  1. Check if the request contains an Idempotency-Key
  2. Look up the key in the database
  3. If the key exists → return stored response
  4. If not → execute the request
  5. Save the response for future retries

Implementation:

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private readonly idempotencyService: IdempotencyService) {}

async intercept(context: ExecutionContext, next: CallHandler) {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest();
const response = httpContext.getResponse();

const key = request.headers['idempotency-key'];

// If request doesn't contain idempotency key,
// just continue normally
if (!key) {
return next.handle();
}

const existing = await this.idempotencyService.find(key);

// If we already processed this request before,
// return stored response immediately
if (existing) {
response.status(existing.statusCode);
return of(existing.response);
}

// Otherwise process request normally
return next.handle().pipe(
tap(async (data) => {
await this.idempotencyService.save(
key,
data,
response.statusCode
);
}),
);
}
}
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private readonly idempotencyService: IdempotencyService) {}

async intercept(context: ExecutionContext, next: CallHandler) {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest();
const response = httpContext.getResponse();

const key = request.headers['idempotency-key'];

// If request doesn't contain idempotency key,
// just continue normally
if (!key) {
return next.handle();
}

const existing = await this.idempotencyService.find(key);

// If we already processed this request before,
// return stored response immediately
if (existing) {
response.status(existing.statusCode);
return of(existing.response);
}

// Otherwise process request normally
return next.handle().pipe(
tap(async (data) => {
await this.idempotencyService.save(
key,
data,
response.statusCode
);
}),
);
}
}

Step 4 — Apply the Interceptor to an Endpoint

Now we apply the interceptor to a payment endpoint.

@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController {
@Post()
async createPayment(@Body() body: CreatePaymentDto) {

// simulate expensive payment processing
await new Promise(resolve => setTimeout(resolve, 2000));

return {
success: true,
transactionId: randomUUID(),
amount: body.amount
};
}
}
@Controller('payments')
@UseInterceptors(IdempotencyInterceptor)
export class PaymentsController {
@Post()
async createPayment(@Body() body: CreatePaymentDto) {

// simulate expensive payment processing
await new Promise(resolve => setTimeout(resolve, 2000));

return {
success: true,
transactionId: randomUUID(),
amount: body.amount
};
}
}

Now if the client sends this request:

POST /payments
Idempotency-Key: abc123
POST /payments
Idempotency-Key: abc123

The server processes the payment and returns:

{
"success": true,
"transactionId": "txn_91238",
"amount": 100
}
{
"success": true,
"transactionId": "txn_91238",
"amount": 100
}

The result is stored in the database.


In retry scenario, if the same request is sent again:

POST /payments
Idempotency-Key: abc123
POST /payments
Idempotency-Key: abc123

Instead of running the payment logic again, the server returns the stored response. The payment logic runs only once.

Step 5 — Client-Side Implementation

The final step is ensuring the client generates a unique key. Example using JavaScript:

await fetch("/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID()
},
body: JSON.stringify({
amount: 100
})
});
await fetch("/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID()
},
body: JSON.stringify({
amount: 100
})
});

But, the important rule is the same key must be reused when retrying the request. This ensures the server treats retries as the same operation.

Conclusion

Idempotency is a crucial technique for building reliable and safe APIs, especially for operations like payments, order creation, or other financial transactions. In real-world systems, network issues, client retries, or accidental double-clicks can easily cause the same request to be sent multiple times. Without proper protection, this could result in duplicate operations, such as charging a user twice.

By implementing idempotency in NestJS, we can ensure that repeated requests with the same idempotency key will return the same result, instead of executing the operation again. Using a combination of a stored idempotency key, a service layer, and a NestJS interceptor, we can add this protection without complicating our business logic.

Production Considerations

When implementing idempotency in production, keep these best practices in mind:

  • Use TTL for idempotency keys Store keys only for a limited time (for example 24 hours) to prevent unnecessary database growth.
  • Prevent race conditions Use database unique constraints, atomic inserts, or locks to ensure two identical requests cannot process simultaneously.
  • Validate request payloads Reject requests that reuse the same idempotency key with a different payload.
  • Apply idempotency only where necessary Focus on critical endpoints such as payments, order creation, or subscription processing.
  • Consider Redis for high-scale systems For very high traffic APIs, using an in-memory store like Redis can improve performance.

In short, idempotency makes APIs safe to retry and resilient to failures. Implementing it early in your backend architecture can prevent serious issues like duplicate payments and inconsistent data as your system grows.

Thank you for reading! If you found this helpful, feel free to share it.