I’m Badii Boutar. I build online stores for clients, mostly in Tunisia and the Gulf, and I built The Merchant Engine, an open-source store with a NestJS API. This post shows how its checkout makes sure one tap on “Place order” creates one order, even when the network does not cooperate. The code is simplified from the real files, but the logic is the same.
The double-tap problem
A customer taps “Place order”. The request reaches the API, the order is created, and then the phone loses signal before the answer comes back. The customer sees a spinner, so they tap again. The browser may also retry on its own.
The API now gets the same order twice. Without protection, it creates two orders, takes the stock twice and, with cash on delivery, sends a courier twice. I covered the storefront side in Cash on delivery checkout done right. This post is about the server.
The key
The client creates one random UUID v4 when the checkout opens and sends it on every try as the X-Idempotency-Key header. In NestJS, a small param decorator reads it and a pipe checks it:
function readIdempotencyKey(_data: unknown, ctx: ExecutionContext) {
const value = ctx.switchToHttp().getRequest().headers['x-idempotency-key'];
return Array.isArray(value) ? value[0] : value;
}
export const IdempotencyKey = createParamDecorator(readIdempotencyKey);
@Injectable()
export class IdempotencyKeyPipe implements PipeTransform {
transform(value: string | undefined): string {
if (!value) throw new IdempotencyKeyRequiredException();
// validateSync on a DTO with @IsUUID('4')
if (!isUuidV4(value)) throw new IdempotencyKeyMalformedException();
return value;
}
}
The controller then asks for it like any other parameter:
@Post()
create(
@Body() dto: CreateOrderDto,
@IdempotencyKey(IdempotencyKeyPipe) idempotencyKey: string,
) { ... }
A missing key answers 400 IDEMPOTENCY_KEY_REQUIRED. A key that is not a UUID v4 answers 400 IDEMPOTENCY_KEY_MALFORMED. I made the key required on purpose. An optional key is a key that half the clients forget.
Two hashes
The key alone is not enough. The service also needs to know who sent it and what they sent.
- The caller hash is a SHA-256 of the user id. Guests have no user id, so they use
guest:plus their cart session id. Without that, every guest would share one caller and one guest could hit another guest’s key. - The body hash is a SHA-256 of the request body, written with its keys sorted. The same body always gives the same hash, whatever order the client put the fields in.
const callerHash = IdempotencyService.sha256Hex(userId ?? `guest:${sessionId}`);
const bodyHash = IdempotencyService.sha256Hex(
IdempotencyService.stableStringify(dto),
);
One atomic claim
The first thing the order service does, before it reads the cart, is claim the key in Redis. One command does it:
const claimed = await this.redis.set(
redisKey,
JSON.stringify({ status: 'in-flight', callerHash, bodyHash }),
'EX', IDEMPOTENCY_TTL_SECONDS,
'NX',
);
NX means “only if the key does not exist yet”. Redis runs it as one step, so if two requests arrive at the same moment, exactly one gets OK. There is no gap between “check” and “write” for a second request to slip into. EX gives the entry a 24 hour life, so the store does not grow forever.
The Redis key is a prefix, the scope (orders) and the first 16 hex characters of a SHA-256 of the raw key. The raw key never lands in Redis or in a log line.
Five states
The claim runs before the try block that creates the order, so a request that is refused here never releases a claim that belongs to another request. If the claim fails, the service reads the stored entry and compares it with the new request. Every case ends in one of five states:
- fresh: the claim worked. Go ahead and create the order.
- cached: same caller, same body, and the first request finished. Return the stored response.
- in-flight: same caller, same body, but the first request is still running. Answer
409 ORDER_IDEMPOTENCY_IN_PROGRESS. The client waits and retries with the same key. - conflict: same caller, different body. Answer
409 IDEMPOTENCY_KEY_CONFLICT. The client changed the cart and must use a new key. - forbidden: a different caller. Answer
403 IDEMPOTENCY_KEY_FORBIDDEN.
In the order service, that is one switch:
switch (begin.state) {
case 'cached':
return begin.cachedResponse;
case 'forbidden':
throw new IdempotencyKeyForbiddenException();
case 'conflict':
throw new IdempotencyKeyConflictException();
case 'in-flight':
throw new OrderIdempotencyInFlightException();
case 'fresh':
break;
}
Each state has its own error code, so the client knows what to do without reading the message.
Cache the response the customer is allowed to see
When the order is created, the service stores the answer so a retry gets the same one. The order is first turned into its customer shape, without admin-only fields like internal notes, and only that shape is stored:
const customerOrder = toCustomerOrder(order);
await this.idempotency.completeRequest({
...idempotencyArgs,
entityId: order.id,
responseJson: customerOrder,
});
If I cached the full database row, a replay would hand the customer fields the normal route never shows. Serializing before caching makes the replay exactly as safe as the first answer. The “order created” event is sent after this step, once.
A replay answers 201 again with the same order. No stock moves and the cart stays empty.
Release on failure
If anything fails after the claim, for example the stock runs out on one line, the service deletes the claim before it throws:
} catch (err) {
await this.idempotency.abortRequest(idempotencyArgs);
throw err;
}
Without this, the entry would stay “in-flight” for 24 hours and every retry would get 409 ORDER_IDEMPOTENCY_IN_PROGRESS, even after the customer fixed their cart. The same catch block also cancels any payment session the order had opened.
When Redis is down
I chose to fail open. If the SET or the GET throws, the service logs a warning and returns fresh, so the checkout keeps taking orders. For a small store, losing sales during a Redis restart is worse than the small risk of a duplicate.
That risk is covered by the database. The Order model has a unique key column:
idempotencyKey String? @unique
Even with Redis gone, a second insert with the same key fails on the unique index instead of creating a second order. Redis gives the nice answers. PostgreSQL gives the guarantee.
Why the Redis key does not include the caller
It looks safer to put the caller in the Redis key, so two callers can never collide. I kept the key global on purpose, because the database column is global too. If Redis allowed two callers with the same key, the second one would pass Redis and then crash on the unique index. With a global Redis key, that case gets a clean 403 IDEMPOTENCY_KEY_FORBIDDEN and the cached order of the first caller is never shown to the second.
Other routes that use it
The same service sits behind every route where a retry must not do the work twice:
- Order creation, from the storefront and from the admin (the admin’s caller hash is the staff user id).
- Payment capture, refund and cancel. The key passed to the payment provider is a hash of the caller, the body and the client key, so the provider sees the same key on a retry too.
- Stock adjustments and transfers, return refunds, SEO submissions and webhook tests.
Each kind of work has its own scope (orders, orders:payment, inventory-adjust and so on), so an order key and a refund key never meet.
What it does not do
- The cached answer lives for 24 hours. After that, a retry with the same order key no longer gets the first order back. The unique column still blocks a second order, but the client gets an error instead.
- While Redis is down, two requests at the same moment both start. The unique column stops the second order, but the client sees an error, not the first order.
- It protects one request from being repeated. It does not stop a customer from placing two real orders on purpose with two keys. That is a business rule, not idempotency.
What I’d do
If you add this to your own NestJS API, start with three things: a required UUID header, an atomic SET NX EX claim with a caller hash and a body hash, and a unique column on the table as the last guard. Release the claim when the work fails, and cache only the shape the client is allowed to see.
The full checkout flow, with the exact requests and errors, is on Checkout and orders. The code is on GitHub under MIT, and you can place a test order on the storefront demo.