All posts

October 8, 2026 ยท 11 min read

Idempotent POST Endpoints in NestJS: Stop Charging Customers Twice

nestjsbackendapi designredis

Every backend developer eventually meets this bug. A user taps "Pay", the spinner hangs, they tap again, and now there are two orders in the database. Or nobody taps anything twice: the mobile app simply retried on its own after a timeout, exactly like it was designed to.

The request wasn't wrong. The retry wasn't wrong either. The endpoint just wasn't built to handle being called twice.

This post walks through how I'd make a POST endpoint safe to retry in NestJS, using idempotency keys, an interceptor and Redis. The code is meant to be copied, but I also want to spend some time on the edge cases, because that's where most implementations quietly break.

What "idempotent" actually means

An operation is idempotent if doing it once or doing it ten times leaves the system in the same state.

HTTP already gives us some of this for free. GET only reads. PUT /users/42 with the same body sets the same values no matter how often you send it. DELETE /users/42 removes the user the first time and does nothing after that.

POST is the odd one out. It usually means "create something new", so every call creates something new. For a comment, a duplicate is annoying. For a payment, a booking or a stock transfer, it's a real problem.

How duplicates happen

The scary part is that the client usually does nothing wrong. Here's the classic sequence:

Sequence diagram: the API charges the customer, the response is lost, the client retries and the API charges again

The first request succeeded. The server just never got to tell anyone. From the client's point of view, a timeout looks exactly like a failure, so retrying is the reasonable thing to do. And it's not only mobile apps: HTTP clients with retry policies, API gateways, message queues with at-least-once delivery and users double-clicking all produce the same pattern.

You can't stop retries. What you can do is make them harmless.

The idea: idempotency keys

The fix most payment APIs use (Stripe made it popular) is simple. The client generates a unique key for each operation and sends it in a header:

POST /payments HTTP/1.1
Idempotency-Key: 8c1e3f6a-2b7d-4c0e-9f51-3a6d2e7b9c10
Content-Type: application/json
 
{ "amount": 4999, "currency": "MAD" }

If the request is retried, it goes out with the same key. The server remembers which keys it has already handled and, instead of running the operation again, returns the response it gave the first time.

The important word is operation. The key is created when the user presses "Pay", not when the HTTP call is sent. If the client generates a fresh UUID on every attempt, none of this works.

What the server needs to get right

Storing the key and checking it later sounds like ten lines of code. It mostly is, but a production version has to handle a few more cases:

  1. Replay: same key, same request, already finished. Return the stored response, don't run the handler.
  2. Misuse: same key, different request body. This is a client bug, so reject it loudly instead of returning a response for a different payment.
  3. Concurrency: the retry arrives while the first request is still running. Two requests must never both get past the check.
  4. Failure: if the handler throws, release the key so the client can try again.
  5. Expiry: keys shouldn't live forever. A day is a common choice.

Eduardo Conti wrote a nice introduction to idempotent endpoints with NestJS interceptors that covers the core idea with an in-memory repository. I wanted to take the same approach and push it toward something I'd trust in production, so points 2 to 5 are what the rest of this post is about.

Here's the whole flow we're about to build:

Flowchart of the idempotency interceptor: claim the key atomically, run the handler once, replay, reject or release

Why an interceptor?

NestJS interceptors wrap the route handler. They run code before it, can skip it entirely, and can see (and transform) what it returns. That's exactly the shape we need: check the key, maybe short-circuit with a stored response, otherwise run the handler and save its result.

I don't want every endpoint to require the header, though. A decorator lets each route opt in.

Step 1: the decorator

idempotent.decorator.ts
import { SetMetadata } from '@nestjs/common';
 
export const IDEMPOTENT = 'idempotent';
 
export interface IdempotentOptions {
  /** How long to remember a key, in seconds. Defaults to 24 hours. */
  ttlSeconds?: number;
}
 
export const Idempotent = (options: IdempotentOptions = {}) =>
  SetMetadata(IDEMPOTENT, options);

Step 2: the store

The interceptor shouldn't care where keys live, so it depends on an abstract class. Using an abstract class instead of an interface means Nest can use it directly as an injection token.

idempotency.store.ts
export type IdempotencyRecord =
  | { status: 'processing'; fingerprint: string }
  | { status: 'completed'; fingerprint: string; body: unknown };
 
export abstract class IdempotencyStore {
  /**
   * Atomically claims the key.
   * Returns null if this request now owns it, or the existing record if not.
   */
  abstract claim(key: string, fingerprint: string, ttlSeconds: number): Promise<IdempotencyRecord | null>;
 
  abstract complete(key: string, fingerprint: string, body: unknown, ttlSeconds: number): Promise<void>;
 
  abstract release(key: string): Promise<void>;
}

Notice that there's no separate "find" and "save". That split is the most common bug I see in idempotency code. If you check whether the key exists and then save it, two requests that arrive a few milliseconds apart can both see "no key" and both run the handler. The check and the claim have to be one atomic operation.

Redis gives us that for free with SET ... NX (only set if the key doesn't exist) and EX (expire after N seconds):

redis-idempotency.store.ts
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { IdempotencyRecord, IdempotencyStore } from './idempotency.store';
 
export const REDIS = Symbol('REDIS');
 
@Injectable()
export class RedisIdempotencyStore extends IdempotencyStore {
  constructor(@Inject(REDIS) private readonly redis: Redis) {
    super();
  }
 
  async claim(key: string, fingerprint: string, ttlSeconds: number): Promise<IdempotencyRecord | null> {
    const record: IdempotencyRecord = { status: 'processing', fingerprint };
    const claimed = await this.redis.set(this.k(key), JSON.stringify(record), 'EX', ttlSeconds, 'NX');
    if (claimed === 'OK') return null;
 
    const existing = await this.redis.get(this.k(key));
    // The key can expire between SET and GET. If it did, just try again.
    return existing ? JSON.parse(existing) : this.claim(key, fingerprint, ttlSeconds);
  }
 
  async complete(key: string, fingerprint: string, body: unknown, ttlSeconds: number): Promise<void> {
    const record: IdempotencyRecord = { status: 'completed', fingerprint, body };
    await this.redis.set(this.k(key), JSON.stringify(record), 'EX', ttlSeconds);
  }
 
  async release(key: string): Promise<void> {
    await this.redis.del(this.k(key));
  }
 
  private k(key: string) {
    return `idempotency:${key}`;
  }
}

Step 3: fingerprinting the request

To catch misuse (same key, different payload), we store a hash of the request next to the key and compare it on every retry:

fingerprint.ts
import { createHash } from 'node:crypto';
import type { Request } from 'express';
 
export function fingerprint(req: Request): string {
  return createHash('sha256')
    .update(req.method)
    .update(req.originalUrl)
    .update(JSON.stringify(req.body ?? {}))
    .digest('hex');
}

One caveat: JSON.stringify depends on key order. {"a":1,"b":2} and {"b":2,"a":1} produce different hashes. Most clients serialize the same object the same way every time, so this rarely matters. If yours don't, sort the keys before hashing.

Step 4: the interceptor

This is where it all comes together:

idempotency.interceptor.ts
import {
  BadRequestException,
  CallHandler,
  ConflictException,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  UnprocessableEntityException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request, Response } from 'express';
import { Observable, catchError, concatMap, from, map, mergeMap, of, throwError } from 'rxjs';
import { IDEMPOTENT, IdempotentOptions } from './idempotent.decorator';
import { IdempotencyStore } from './idempotency.store';
import { fingerprint } from './fingerprint';
 
const HEADER = 'idempotency-key';
const DEFAULT_TTL = 60 * 60 * 24; // 24 hours
 
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly store: IdempotencyStore,
  ) {}
 
  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
    const options = this.reflector.get<IdempotentOptions>(IDEMPOTENT, context.getHandler());
    if (!options) return next.handle(); // route didn't opt in
 
    const http = context.switchToHttp();
    const req = http.getRequest<Request & { user?: { id: string } }>();
    const res = http.getResponse<Response>();
 
    const rawKey = req.header(HEADER);
    if (!rawKey || rawKey.length > 255) {
      throw new BadRequestException(`A valid ${HEADER} header is required`);
    }
 
    // Scope keys per user so one client can never replay another client's response.
    const key = `${req.user?.id ?? 'anonymous'}:${rawKey}`;
    const ttl = options.ttlSeconds ?? DEFAULT_TTL;
    const fp = fingerprint(req);
 
    const existing = await this.store.claim(key, fp, ttl);
 
    if (existing) {
      if (existing.fingerprint !== fp) {
        throw new UnprocessableEntityException('This idempotency key was already used for a different request');
      }
      if (existing.status === 'processing') {
        throw new ConflictException('A request with this idempotency key is still in progress');
      }
      res.setHeader('Idempotent-Replayed', 'true');
      return of(existing.body);
    }
 
    return next.handle().pipe(
      // Wait for the response to be saved before sending it.
      concatMap((body) => from(this.store.complete(key, fp, body, ttl)).pipe(map(() => body))),
      // If anything failed, free the key so the client can retry.
      catchError((err) => from(this.store.release(key)).pipe(mergeMap(() => throwError(() => err)))),
    );
  }
}

A few details here are easy to miss:

  • concatMap instead of tap. If you save the response inside a tap with an async callback, nothing waits for it. The response goes out, the save happens "eventually", and a fast retry can still see processing. concatMap makes the response wait until Redis has confirmed the write.
  • catchError releases the key. Without it, a request that crashes halfway leaves the key stuck in processing until the TTL runs out, and every retry gets a 409 for the next 24 hours.
  • The status code is replayed for free. When an interceptor returns a value, Nest still applies the route's status code (201 for POST, or whatever @HttpCode says). A replayed response gets the same status as the original without any extra work.
  • The Idempotent-Replayed header is optional, but it makes debugging much nicer. When someone asks "why didn't my second payment go through?", you can see straight away that it was a replay.

Step 5: wiring it up

Register the store and the interceptor globally. Routes without @Idempotent() aren't affected:

app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import Redis from 'ioredis';
import { IdempotencyInterceptor } from './idempotency/idempotency.interceptor';
import { IdempotencyStore } from './idempotency/idempotency.store';
import { REDIS, RedisIdempotencyStore } from './idempotency/redis-idempotency.store';
import { PaymentsModule } from './payments/payments.module';
 
@Module({
  imports: [PaymentsModule],
  providers: [
    { provide: REDIS, useFactory: () => new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') },
    { provide: IdempotencyStore, useClass: RedisIdempotencyStore },
    { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
  ],
})
export class AppModule {}

And on the endpoint itself:

payments.controller.ts
@Controller('payments')
export class PaymentsController {
  constructor(private readonly payments: PaymentsService) {}
 
  @Post()
  @Idempotent()
  create(@Body() dto: CreatePaymentDto) {
    return this.payments.charge(dto);
  }
}

That's it: one decorator per route.

Trying it out

First request, normal response:

curl -i -X POST http://localhost:3000/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 8c1e3f6a-2b7d-4c0e-9f51-3a6d2e7b9c10' \
  -d '{"amount": 4999, "currency": "MAD"}'
HTTP/1.1 201 Created
Content-Type: application/json
 
{"id":"pay_7Qx2","amount":4999,"currency":"MAD","status":"succeeded"}

Run the exact same command again. Same body, same payment ID, and no second charge:

HTTP/1.1 201 Created
Idempotent-Replayed: true
Content-Type: application/json
 
{"id":"pay_7Qx2","amount":4999,"currency":"MAD","status":"succeeded"}

Now reuse the key with a different amount:

HTTP/1.1 422 Unprocessable Entity
 
{"statusCode":422,"message":"This idempotency key was already used for a different request"}

To see the concurrency protection, add an artificial delay to charge() and fire two requests at once (put a & after the first curl). One gets the 201, the other gets a 409 Conflict, and the payment service is called exactly once.

Things I'd think about before shipping this

Use a shared store. An in-memory map only works with a single instance. As soon as you have two pods behind a load balancer, the retry can land on the one that has never seen the key. Redis (or a database table with a unique constraint) solves that.

Pick the TTL deliberately. It has to be longer than the longest time a client might keep retrying. 24 hours is a common default. Longer TTLs cost memory, so if you store large responses, keep an eye on Redis usage.

Decide what to do with 4xx errors. The interceptor above releases the key on any error, so a request that failed validation can be retried with the same key. Some APIs prefer to store 4xx responses too, so a broken request keeps returning the same error. Both are valid. Just pick one and document it.

Keep your database constraints. Idempotency keys protect the HTTP layer. They don't replace a unique index on something like order_reference. Defense in depth is cheap here.

Tell your clients how it works. Document that the key must be generated once per operation and reused on retries. The server side is only half of the contract.

Wrapping up

Retries will happen whether you plan for them or not. An idempotency key turns "did my payment go through?" from a support ticket into a non-event.

The core of it is small: claim the key atomically, run the handler once, store the result, and replay it. The extra hundred lines go into the edge cases: concurrent duplicates, mismatched payloads, failures and expiry. Those are exactly the cases that show up in production at the worst possible moment.

If you've built something similar and handled any of these differently, I'd genuinely like to hear about it.