Express middleware for idempotent POSTs via Idempotency-Key with in-flight control and pluggable stores.
npm install express-idempotency-middleware
Express middleware that makes unsafe HTTP requests (mainly POST) idempotent using an Idempotency-Key.
The first request executes your handler and caches {status, body, headers(whitelist)} for a TTL. Identical retries return the cached response. Conflicting payloads get 409 Conflict. Concurrency is handled via wait or reject strategies.
---
- Drop-in per-route middleware
- TypeScript-first, ESM-only, Node ≥ 18
- Pluggable stores: built-in Memory (dev). Example Redis/Postgres stores in examples/
- In-flight control: wait (with timeout) or reject
- Safe replay with header whitelist (never replays cookies/auth)
- Stable fingerprint: method + path + normalized body + optional tenant/user
- Designed for payments, orders, webhooks, and similar at-least-once scenarios
---
``bash`
npm i express-idempotency-middlewarepeer
npm i express
> ESM-only: your project should use "type": "module" or native ESM (Node 18+).
---
`ts
import express from "express";
import { idempotencyMiddleware, MemoryStore } from "express-idempotency-middleware";
const app = express();
app.use(express.json());
const store = new MemoryStore();
app.post(
"/payments",
idempotencyMiddleware({
store,
ttlMs: 24 60 60 * 1000,
inFlight: { strategy: "wait", waitTimeoutMs: 3000, pollMs: 100 },
replay: { headerWhitelist: ["location"] }
}),
async (req, res) => {
// your business logic
const orderId = "ord_" + Math.random().toString(36).slice(2);
res.setHeader("Location", /orders/${orderId});
res.status(201).json({ orderId });
}
);
app.listen(3000);
`
Client header:
``
Idempotency-Key:
---
This repo ships with runnable examples under examples/. The quickest way to try the middleware is the basic Express server.
`bash`
npm i
npm run build
node dist/examples/server-basic.jsServer: http://localhost:3000
`bash`
curl -i -X POST http://localhost:3000/payments -H "Content-Type: application/json" -H "Idempotency-Key: key-123" -d '{"amount":100}'
Expected:
- HTTP/1.1 201 CreatedIdempotency-Status: created
- {"orderId":"..."}
- Body:
`bash`
curl -i -X POST http://localhost:3000/payments -H "Content-Type: application/json" -H "Idempotency-Key: key-123" -d '{"amount":100}'
Expected:
- HTTP/1.1 201 Created (same status as the first response)Idempotency-Status: cached
- Idempotency-Replayed: true
- Content-Type: application/json; charset=utf-8
- orderId
- Body: identical to the first response (same )
`bash`
curl -i -X POST http://localhost:3000/payments -H "Content-Type: application/json" -H "Idempotency-Key: key-123" -d '{"amount":200}'
Expected:
- HTTP/1.1 409 ConflictIdempotency-Status: conflict
-
The example route simulates ~300 ms of work and uses inFlight: { strategy: "wait", waitTimeoutMs: 3000 }.
Open two terminals and run the same request with the same key as fast as possible.
The second request will wait and return the cached result:
- Idempotency-Status: cachedIdempotency-Replayed: true
-
---
`ts
import type { RequestHandler, Request } from "express";
function idempotencyMiddleware(options: IdemOptions): RequestHandler;
export type IdemOptions = {
store: Store;
ttlMs?: number; // default 24h
methods?: string[]; // default ["POST"]
keyHeader?: string; // default "Idempotency-Key"
requireKey?: boolean; // default false (400 if true and missing)
inFlight?: { // default {strategy: "reject"}
strategy: "wait" | "reject";
waitTimeoutMs?: number; // default 5000
pollMs?: number; // default 100
};
fingerprint?: {
includeQuery?: boolean; // default false
maxBodyBytes?: number; // default 64KB
custom?: (req: Request) => string | undefined; // e.g., tenant/user id
};
replay?: {
headerWhitelist?: string[]; // lowercase names, e.g., ["location"]
};
};
`
`ts
export type CachedResponse = {
status: number;
body: string | Buffer;
headers: Record
fingerprint: string;
createdAt: number;
};
export interface Store {
begin(key: string, fp: string, ttlMs: number): Promise<
| { kind: "started" }
| { kind: "replay"; cached: CachedResponse }
| { kind: "conflict" }
| { kind: "inflight" }
>;
commit(key: string, data: CachedResponse): Promise
get(key: string): Promise
}
`
---
- Adds response headers:
- Idempotency-Key: echoes the keyIdempotency-Status
- : created | cached | conflict | inflight | inflight-timeout | missing-keyIdempotency-Replayed
- : true | falseRetry-After: 1
- On in-flight timeout or reject: replay.headerWhitelist
- Replay headers: only those in are replayed, plus content-type is always replayed.set-cookie
Sensitive headers (, authorization, www-authenticate, proxy-*) are never replayed.
---
Redis and Postgres stores are provided as examples (no hard deps).examples/redis-store.ts
See and examples/postgres-store.ts for sketches.
Typical approach (Redis sketch):
`ts
// requires: npm i redis
// import { createClient } from "redis";
// const client = createClient({ url: process.env.REDIS_URL });
// await client.connect();
import type { Store, CachedResponse } from "express-idempotency-middleware";
class RedisStore implements Store {
async begin(key: string, fp: string, ttlMs: number) {
// Use SETNX + PX (or a Lua script) to atomically claim the key
// Return: {kind:"started"} | {kind:"replay", cached} | {kind:"conflict"} | {kind:"inflight"}
return { kind: "started" };
}
async commit(key: string, data: CachedResponse) {
// Persist final response (JSON + keep TTL)
}
async get(key: string) {
// Read cached response (if any)
return null;
}
}
`
Typical approach (Postgres sketch):
`sql`
-- One possible schema (sketch)
CREATE TABLE idem_keys (
key text PRIMARY KEY,
fp text NOT NULL,
state text NOT NULL CHECK (state IN ('inflight','done')),
created_at timestamptz NOT NULL DEFAULT now(),
expiry timestamptz NOT NULL,
status int,
headers jsonb,
body bytea
);
CREATE INDEX ON idem_keys (expiry);`ts`
// Use INSERT ... ON CONFLICT to claim/update atomically inside a transaction.
> Keep TTL moderate (hours). Store only safe headers. Avoid caching 5xx responses.
---
- Generate the key client-side (UUID v4) per unsafe request
- Fingerprint only what’s necessary (method, path, normalized body, tenant/user)
- Use a centralized store (Redis/PG) in production; MemoryStore is for dev/tests
- Whitelist only safe headers to replay (e.g., location); content-type is always replayedevent.id
- Keep TTL short (hours, not days). Consider background cleanup for SQL stores
- For webhooks, prefer provider event IDs (e.g., Stripe ) as the idempotency key
---
- Not designed for streaming responses or long-running jobs
For multi-minute operations, prefer queues/outbox + status resources
- MemoryStore is single-process only and volatile; use Redis/PG in production
---
- ERR_MODULE_NOT_FOUND after build
Ensure compiled imports include explicit .js extensions and your package.json exports point to ./dist/src/index.js.
- Second request shows created instead of cached
Ensure you’re on a version where the MemoryStore uses a sane fallback TTL and the middleware commits on finish.
- r2.body is {} or a JSON string in tests
Make sure Content-Type: application/json is set and that replay includes content-type (the middleware always replays it by default).
---
`bash``
npm i
npm run build
npm testExample server (after build):
node dist/examples/server-basic.js
---
MIT