Idempotency in Payments, Bookings, Bids and Event Consumers
Why duplicates are normal
I do not design transactional systems assuming a request arrives once.
A mobile user can submit a booking, the server can complete it, and the network can time out before the response reaches the phone. The application retries.
An auction supplier can click submit while a browser or gateway repeats the same request.
A Kafka consumer can write successfully and crash before committing its offset.
Duplicates are a normal network behavior.
Booking example
Without idempotency:
POST booking -> booking B1001
client times out
POST booking -> booking B1002With a key:
POST /bookings
Idempotency-Key: 6c6390dd-2a0f-4cef-a1da-5e5afe54d32cThe same business request returns the original result.
Schema
CREATE TABLE idempotency_keys (
scope VARCHAR(100) NOT NULL,
idempotency_key VARCHAR(100) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(30) NOT NULL,
response_code INT NULL,
response_body JSON NULL,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (scope, idempotency_key)
);The scope matters. The same string may be valid independently for different APIs/tenants.
The request_hash protects against a client accidentally reusing the same key for a different operation.
NestJS transaction
async createBooking(
key: string,
command: CreateBookingCommand,
) {
const fingerprint = sha256(
canonicalJson(command),
);
return this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(IdempotencyEntity);
const existing = await repo.findOne({
where: {
scope: 'CREATE_BOOKING',
idempotencyKey: key,
},
lock: { mode: 'pessimistic_write' },
});
if (existing) {
if (existing.requestHash !== fingerprint) {
throw new ConflictException(
'Idempotency key reused with different request',
);
}
if (existing.status === 'COMPLETED') {
return existing.responseBody;
}
throw new ConflictException(
'Operation is already in progress',
);
}
await repo.insert({
scope: 'CREATE_BOOKING',
idempotencyKey: key,
requestHash: fingerprint,
status: 'IN_PROGRESS',
expiresAt: addHours(new Date(), 24),
});
const booking = await this.createBookingInside(manager, command);
await repo.update(
{ scope: 'CREATE_BOOKING', idempotencyKey: key },
{
status: 'COMPLETED',
responseCode: 201,
responseBody: booking,
},
);
return booking;
});
}In high-throughput systems I prefer a unique constraint/conditional insert over a plain read-then-write sequence because two identical requests can arrive concurrently.
Payment capture
Payment is where idempotency becomes essential.
The key should represent the business operation:
merchant-order-id + payment-attempt-idA timeout after capture creates an unknown result, not permission to charge again.
I query the provider by merchant reference before deciding to retry.
E-auction bid submission
Auction idempotency is subtle because two requests from the same supplier can be either:
- duplicate transmission of the same bid;
- a deliberate new bid.
The key therefore should be tied to a bid submission operation, not merely supplier + auction.
I also preserve the business timestamp and server receive timestamp separately.
Kafka consumer idempotency
CREATE TABLE processed_events (
consumer_name VARCHAR(100) NOT NULL,
event_id VARCHAR(100) NOT NULL,
processed_at DATETIME NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);Handler:
await db.transaction(async (tx) => {
const inserted = await tx.processedEvents.insertIgnore({
consumerName: 'notification-service',
eventId: event.id,
});
if (!inserted) {
return;
}
await tx.notifications.create(
buildNotification(event),
);
});The dedup marker and business effect belong in the same transaction when possible.
Retention
Keeping every idempotency key forever is expensive.
Retention must exceed the realistic retry window.
Examples:
public API retry window: 24 hours
payment reconciliation: longer
Kafka replay history: depends on event retentionI never select retention as an arbitrary TTL without understanding the replay behavior.
Failure scenarios
Same key, different payload
Reject it. Silently returning an unrelated old response can corrupt business behavior.
Two duplicates at exactly the same time
Use database uniqueness or atomic compare-and-set.
Process crashes while status is IN_PROGRESS
Use a lease/timeout and reconciliation state so the key does not remain locked forever.
Response is too large to store
Store the resulting business resource ID and reconstruct the response if needed.
Production checklist
- Identify the true business operation.
- Require a stable key.
- Store a request fingerprint.
- Enforce uniqueness atomically.
- Persist result with the business transaction.
- Handle IN_PROGRESS recovery.
- Choose retention from actual retry/replay windows.
- Use idempotent external provider references.
- Test concurrent duplicates.
- Monitor duplicate rate.
FAQ
Is PUT automatically idempotent?
HTTP defines PUT as idempotent semantics, but an implementation can still create duplicate external side effects if designed incorrectly.
Does Kafka exactly-once remove the need for idempotency?
Not when the business transaction includes databases or external APIs outside the Kafka transaction boundary.
Should every endpoint require an idempotency key?
No. I require it for side-effecting operations where clients or infrastructure can repeat a request.
Related architecture guides
- [Microservices Design Patterns I Have Used in Real Enterprise Projects](/articles/microservices-design-patterns-real-enterprise-projects)
- [Saga Pattern](/articles/saga-pattern-flight-hotel-payment-procurement)
-
Architect's final take
If I cannot explain how a duplicate booking, payment, bid or event is handled, I do not consider the operation production-ready.
Your feedback helps prioritize deeper technical content.






