Picture a customer tapping send on a payment, the app freezes for a second, and out of habit they tap it again. If your backend is not built to handle that gracefully, the customer just paid twice, and now you have a support ticket, a refund to process, and a customer who no longer trusts your app with their money.
Most developers know the standard fix for this. You generate a unique id for the request, attach it to the call, and check whether that id has already been processed before doing anything. This works well and solves the immediate problem of a single call being retried.
But there is a second, quieter version of this problem that catches a lot of backends off guard. What happens when the same logical transaction shows up again, not as an obvious retry, but days or weeks later, from a different source, with slightly different metadata. A transaction id check alone will not save you here, because in this case the ids might not even match.
Retry safe idempotency, the version most people already know
The common approach is to generate an idempotency key on the client side, send it with the request, and store it once the request has been processed. If the same key arrives again, you know it is a retry of the same request, not a new one.
@Injectable()
export class PaymentService {
constructor(
@InjectRepository(IdempotencyRecord)
private readonly idempotencyRepo: Repository<IdempotencyRecord>,
@InjectRepository(Payment)
private readonly paymentRepo: Repository<Payment>,
) {}
async processPayment(idempotencyKey: string, dto: CreatePaymentDto): Promise<Payment> {
const existing = await this.idempotencyRepo.findOne({
where: { key: idempotencyKey },
});
if (existing) {
return this.paymentRepo.findOneOrFail({
where: { id: existing.paymentId },
});
}
const payment = await this.paymentRepo.save(
this.paymentRepo.create({
amount: dto.amount,
currency: dto.currency,
recipientId: dto.recipientId,
}),
);
await this.idempotencyRepo.save({
key: idempotencyKey,
paymentId: payment.id,
});
return payment;
}
}
A NestJS interceptor is a natural place to enforce this at the request level, so every controller that needs idempotency does not have to remember to implement the check itself.
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(
@InjectRepository(IdempotencyRecord)
private readonly idempotencyRepo: Repository<IdempotencyRecord>,
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const key = request.headers['idempotency key'];
if (!key) {
throw new BadRequestException('Missing idempotency key');
}
const existing = await this.idempotencyRepo.findOne({ where: { key } });
if (existing) {
return of({ alreadyProcessed: true, paymentId: existing.paymentId });
}
return next.handle();
}
}
This handles the case where the exact same request is retried because of a slow network, a client side bug, or a nervous user tapping the button twice. It does not handle the second, harder problem.
Content based idempotency, the version people usually miss
Consider an external bank sending you a transaction record, one you already picked up and stored yesterday. Today it arrives again, but this time the provider has assigned it a slightly different internal reference, or it comes through a different sync batch than before. A pure id based check will not catch this, because from the id's point of view, it looks like a brand new transaction.
What actually identifies this transaction is not the id the provider happens to attach to it this time, but the content of the transaction itself, the account, the amount, the timestamp, and the counterparty. This calls for a different kind of key, one derived from the content, not from an id that might not stay stable across sources or over time.
import { createHash } from 'crypto';
@Injectable()
export class ContentIdempotencyService {
generateContentKey(transaction: RawBankTransaction): string {
const fingerprint = [
transaction.accountId,
transaction.amount,
transaction.currency,
transaction.counterpartyRef,
transaction.postedAt,
].join('|');
return createHash('sha256').update(fingerprint).digest('hex');
}
}
The fields chosen here matter. They need to be values that genuinely identify the transaction as a real world event, not values that are likely to shift between sync attempts. Amount, account, currency, counterparty, and the date it was posted are usually stable. A locally generated timestamp or a provider batch id are not, and including those would defeat the purpose entirely.
Combining both layers in one ingestion path
In a real system, you often need both checks working together, one for exact retries of the same call, and one for the same underlying event arriving through a different path entirely.
@Injectable()
export class TransactionIngestionService {
constructor(
private readonly contentIdempotencyService: ContentIdempotencyService,
@InjectRepository(ExternalTransaction)
private readonly externalTransactionRepo: Repository<ExternalTransaction>,
) {}
async ingest(transaction: RawBankTransaction): Promise<void> {
const byProviderId = await this.externalTransactionRepo.findOne({
where: { providerTransactionId: transaction.providerTransactionId },
});
if (byProviderId) {
return;
}
const contentKey = this.contentIdempotencyService.generateContentKey(transaction);
const byContent = await this.externalTransactionRepo.findOne({
where: { contentKey },
});
if (byContent) {
return;
}
await this.externalTransactionRepo.save({
providerTransactionId: transaction.providerTransactionId,
contentKey,
amount: transaction.amount,
currency: transaction.currency,
accountId: transaction.accountId,
postedAt: transaction.postedAt,
});
}
}
The provider id check catches the fast, obvious duplicates. The content key check catches the slower, sneakier ones, where the same real world event reaches you again wearing a slightly different identity.
The bigger picture
NestJS will not decide for you which fields belong in a content based fingerprint, that judgment call depends entirely on your domain and the specific provider you are working with. What it gives you is a clean place to structure this logic, a dedicated service for generating the key, a repository layer for checking it, and an ingestion service that ties both layers of protection together without scattering duplicate checks across your codebase.
The habit worth building here is to ask, whenever data enters your system from outside, not just whether this exact call has been seen before, but whether this exact event, described by its content, has been seen before. Those are two different questions, and a backend that only asks the first one still has a gap waiting to be found.
If you are building a system that pulls in financial data from multiple sources or sync paths and want duplicate handling done properly from day one, this is a good problem to solve early, before duplicate records start quietly affecting your reports and reconciliation.
I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.
LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi
Top comments (1)
The useful distinction here is between request replay and event replay. A lot of teams implement the first with an idempotency key and then get surprised when the same underlying transaction reappears later with a different provider reference and slips through anyway. I also like the emphasis on choosing fingerprint fields that describe the business event rather than the transport event, because that is where most content-hash designs go wrong. In practice the cleanest systems usually need both layers: transport-level idempotency for retries and domain-level dedupe for the same real-world event arriving through a different path.