Skip to content

idempotency-keys ​

npm · Source · Changelog

Server-side support for the Idempotency-Key header, so clients can safely retry a POST. Works across instances on a shared store, with Postgres and Valkey built in, in TypeScript and Go.

A client whose connection drops while it sends a POST can't tell whether the server got the request, and a retry might do the work twice. The Idempotency-Key header, popularized by Stripe and now an IETF draft, lets the client send the same unique key with every attempt, so the server can answer a retry with the response it already gave.

idempotency-keys handles the header on the server. handle() runs the first request with a key and stores its response in a store every instance shares: Postgres or Valkey, or one you write. Later requests with that key get the stored response instead of running the handler again, whichever instance they reach.

ts
import { 
IdempotencyKeys
} from 'idempotency-keys';
import {
PostgresStore
} from 'idempotency-keys/postgres';
const
keys
= new
IdempotencyKeys
({
store
: new
PostgresStore
({
client
:
pool
}) });
// Any handler over fetch's Request and Response: Hono, Bun, Deno, Next.js route handlers... export const
POST
= async (
request
: Request):
Promise
<Response> =>
keys
.
handle
(
request
, {
scope
: await
userId
(
request
),
operation
: 'createNote' },
createNote
);

Get started →

Features ​

  • A duplicate that arrives while the first request is still running waits for its response, and gets a 409 if the wait runs out. Duplicates after that get the stored response, marked Idempotent-Replayed: true.
  • A key that comes back on a different request gets a 422 instead of the first request's response. The method, path, query, body and any headers you name all have to match.
  • A request's claim on its key is a lease, so if its server crashes, a waiting request takes over once the lease runs out. See Which responses are stored.
  • You pass your own pg pool or ioredis connection, so the package has no client dependency. Postgres keeps keys durably, while Valkey can forget one after a failover. Any other database works through a store you write to a short contract. See Stores.
  • Notifications through Postgres LISTEN or Valkey pub/sub wake a waiting request as soon as the running one ends, on any instance. Waiting requests poll the store either way, so a notifier only makes waiting faster. See Notifiers.
  • The TypeScript and Go implementations follow one specification, so servers in either language can share a store and its keys. Both implement version 1.0 of the specification. See Go.

Status codes follow the IETF draft The Idempotency-Key HTTP Header Field (draft 07). For payments, or any write that must not run twice whatever happens to the store, use the Postgres store rather than Valkey.

Compared to ​

The other npm packages for Idempotency-Key that we looked at, as their READMEs describe them (versions as of November 2026). We wrote idempotency-keys, so read their docs too.

idempotency-keys@node-idempotency/core 1.1.0hono-idempotency 0.9.1express-idempotency 2.1.0
FrameworksAny over fetch's Request, others through begin(), Go net/httpExpress, Fastify, NestJS, others through the coreHonoExpress
StoresPostgres, Valkey or Redis, or your ownMemory, Redis, PostgresMemory, Redis, Cloudflare KV, D1, Durable ObjectsMemory, MongoDB (separate adapter)
A duplicate while the first runsWaits up to 10 s (woken early by a notifier), then 409Error at once, or polls when inProgressStrategy.wait is set409409
A key reused on a different request422: method, path, query, body and named headersError: the body422: method, path and body417: method, URL, query and body
Responses storedAll but a 429, by a policy you can changeResponses and errors2xx only2xx by default, configurable
A request that crashed mid-runTaken over once its lease (60 s) runs outNot documentedNot documentedTaken over after processingTimeout, if set; otherwise 409 until the key expires
Replay headerIdempotent-Replayed: trueNot documentedIdempotency-Replayed: trueNot documented
Other languages on the same keysGoNone documentedNone documentedNone documented

The Go modules we looked at, the same way (versions as of November 2026):

idempotency-keys (Go)idempo 1.0.0gopotency 1.0.0
Frameworksnet/http middleware, others through BeginAny func(http.Handler) http.Handler: net/http, chi, Gin, EchoGin, net/http, Echo, Fiber
StoresPostgres, Valkey or Redis, or your ownMemory, Redis, Postgres, or your ownMemory, Redis, SQL (Postgres, SQLite), GORM
A duplicate while the first runsWaits up to 10 s (woken early by a notifier), then 409409 at onceAn in-progress error; the status isn't documented
A key reused on a different request422: method, path, query, body and named headers422: method, path and bodyA mismatch error: a request hash (the body by default)
Responses storedAll but a 429, by a policy you can changeUp to 1 MiB; which statuses isn't documentedSuccessful ones; which statuses isn't documented
A request that crashed mid-runTaken over once its lease (60 s) runs outReleased when the handler fails or panics; a crashed one is held for its lock TTLThe lock expires after LockTimeout (5 min)
Replay headerIdempotent-Replayed: trueIdempotency-Replayed: trueNot documented
Other languages on the same keysTypeScriptNone documentedNone documented

The defaults are close to how Stripe's API (v1) describes its own keys: up to 255 characters, forgotten after 24 hours (Stripe: at least 24 hours), a reused key checked against the first request's parameters, and a 500 replayed like any other response once the handler has started. As at Stripe, a request answered before it runs (a 409, or a key that can't be read) stores nothing, so it can be retried.

Packages

  • lease-cache

    A read-through cache for Valkey or Redis that shares one load per key across all your servers, and handles invalidating a key while it loads.

Used in demos

  • OpenAPI

    A small notes API built properly from the spec down to the database, with an OpenAPI 3.1 document written with Juniper, genever types for a typed client and a Hono server that validates every request and response, and Drizzle on Postgres with a database per test.