---
title: Idempotency Keys for a Node.js API with Postgres (2026)
description: 'Make POST and PATCH retry-safe. Build a production idempotency-key layer in TypeScript on Node 24 with Fastify 5 and Postgres 18: atomic claims, 409s, replays.'
url: https://nerdleveltech.com/idempotency-keys-nodejs-postgres-api-tutorial
language: en
date: '2026-06-06'
category: backend
tags:
  - idempotency
  - postgres
  - fastify
  - typescript
  - api design
  - backend
  - node.js
  - reliability
markdown_url: https://nerdleveltech.com/idempotency-keys-nodejs-postgres-api-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/idempotency-keys-nodejs-postgres-api-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Idempotency Keys for a Node.js API with Postgres (2026)

An idempotency key makes an unsafe `POST` or `PATCH` retry-safe: the client sends a unique `Idempotency-Key` header, and the server records it in Postgres so a retried request replays the original response instead of acting twice. This guide builds that layer in TypeScript on Node 24, end to end.

## TL;DR

This hands-on guide builds a production-shaped idempotency layer for a Node.js API using **Fastify 5.8.5**[^fastify], **node-postgres 8.21.0**[^pg], and **Postgres 18.4**[^pg18] on Node 24 LTS. You will model an `idempotency_keys` table, fingerprint each request so a reused key with a different body is rejected, claim the key with a single atomic `INSERT ... ON CONFLICT`, and branch into the five outcomes the IETF draft spells out[^ietf]: process once, replay a completed response, return `409` for a concurrent retry, `422` for a reused key, and `400` when the header is missing. Every file was type-checked under strict TypeScript 6.0.3 and run end to end against Postgres on 6 June 2026. Budget about 35–45 minutes.

## What you'll learn

- Why `POST` and `PATCH` need idempotency keys, and what the IETF `Idempotency-Key` draft requires
- How to design an `idempotency_keys` table that scopes keys per account
- How to fingerprint a request so a reused key with a different payload is caught
- How to claim a key atomically with `INSERT ... ON CONFLICT DO NOTHING`
- How to wire the whole thing as a Fastify `preHandler` hook that returns `400`, `409`, `422`, or a replay
- How to expire and clean up stored keys without bloating the table
- The concurrency trade-off between committing the claim first and holding a transaction open

## Prerequisites

- **Node.js 24** (Active LTS, supported through April 2028) or Node 22 Maintenance LTS[^node]. Node 24 runs `.ts` files directly, so there is no build step in dev.
- **Docker** with the official **`postgres:18.4`** image[^pg18].
- Basic familiarity with HTTP verbs and `async`/`await`. No prior Fastify experience required.

Versions are pinned throughout. Pasting `latest` into a tutorial is how a working example rots three weeks later.

## Why POST needs an idempotency key

Per RFC 9110, `GET`, `PUT`, and `DELETE` are idempotent — sending them twice has the same effect as sending them once. `POST` and `PATCH` are not[^rfc9110]. That is fine until a network blip times out a "create payment" request. The client never saw a response, so it retries. If the first request actually succeeded on the server, the customer is now charged twice.

An idempotency key fixes this. The client generates a unique value — a UUID is recommended[^ietf] — and sends it with every retry of the *same* logical operation:

```http
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
X-Account-Id: acct_123
Content-Type: application/json

{ "amount": 5000, "currency": "usd" }
```

The IETF draft formally defines the header value as a quoted structured-header String[^ietf], but most production APIs — Stripe among them[^stripe] — and the handler we build here treat whatever the client sends as an opaque token, so the examples use the bare UUID.

The server remembers that key. The first time it sees it, it does the work and stores the result. Every later request with the same key gets the *stored* result back — no second charge. Stripe, Adyen, Dwolla, and WorldPay all implement exactly this header[^ietf], and the pattern was popularized by Stripe's engineering write-ups[^stripe] and Brandur Leach's canonical Postgres design[^brandur].

The decision the server makes on every request looks like this:

```mermaid
flowchart TD
  A[Request with Idempotency-Key] --> B{INSERT ... ON CONFLICT<br/>DO NOTHING — row inserted?}
  B -- yes, first time --> C[Process the operation<br/>store response, return 201]
  B -- no, key exists --> D{Fingerprint matches<br/>stored fingerprint?}
  D -- no --> E[422 — key reused<br/>with a different body]
  D -- yes --> F{Status?}
  F -- in_progress --> G[409 — a request is<br/>still outstanding]
  F -- completed --> H[Replay stored<br/>status + body]
```

## Step 1 — Scaffold the project

Create a directory and pin every dependency. The `--save-exact` flag matters: without it, `npm` writes a caret range and the next install can resolve a higher patch than the one you tested.

```bash
mkdir idempotent-api && cd idempotent-api
npm init -y
npm pkg set type=module
npm install --save-exact fastify@5.8.5 pg@8.21.0
npm install --save-exact -D typescript@6.0.3 tsx@4.22.4 @types/node@24.13.1 @types/pg@8.20.0
```

Add a strict `tsconfig.json`. `allowImportingTsExtensions` lets you write the explicit `.ts` import extensions that Node 24's native TypeScript loader requires:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src"]
}
```

Start Postgres in Docker:

```bash
docker run --name idem-pg -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 -d postgres:18.4
```

## Step 2 — Design the idempotency_keys table

The table is the whole system. Each row tracks one operation's progress and caches its eventual response.

```sql
-- schema.sql
CREATE TABLE IF NOT EXISTS idempotency_keys (
  account_id          text        NOT NULL,
  idempotency_key     text        NOT NULL,
  request_fingerprint text        NOT NULL,
  status              text        NOT NULL DEFAULT 'in_progress'
                                  CHECK (status IN ('in_progress', 'completed')),
  response_code       integer,
  response_body       jsonb,
  created_at          timestamptz NOT NULL DEFAULT now(),
  locked_at           timestamptz NOT NULL DEFAULT now(),
  expires_at          timestamptz NOT NULL DEFAULT now() + interval '24 hours',
  PRIMARY KEY (account_id, idempotency_key)
);
```

Three design choices are doing real work here.

The **primary key is composite** — `(account_id, idempotency_key)`, not the key alone. The IETF draft's security section recommends combining the client key with a server-known attribute so one tenant cannot guess another tenant's keys and read their cached responses[^ietf]. Scoping by account also means two customers can independently pick the same UUID without colliding.

The **`status` column is a tiny state machine**: a row is born `in_progress` and flips to `completed` once the work is done and the response is stored. A `CHECK` constraint keeps it honest.

The **`request_fingerprint`** column lets you detect a key being reused with a *different* payload, which the spec says to reject with a `422`[^ietf]. We compute it next.

Apply the schema:

```bash
docker exec -i idem-pg psql -U postgres < schema.sql
```

## Step 3 — Fingerprint the request

The fingerprint is a hash of the parts of the request that define the operation: method, path, and body. Hash the body through a *stable* stringifier so that `{"amount":5000,"currency":"usd"}` and `{"currency":"usd","amount":5000}` produce the same fingerprint — JSON key order should never trigger a false `422`.

```typescript
// src/fingerprint.ts
import { createHash } from 'node:crypto';

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };

export function stableStringify(value: Json): string {
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
  const keys = Object.keys(value).sort();
  return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k] as Json)).join(',') + '}';
}

export function requestFingerprint(method: string, path: string, body: Json): string {
  return createHash('sha256')
    .update(`${method}\n${path}\n${stableStringify(body)}`)
    .digest('hex');
}
```

`node:crypto` ships with Node, so there is no dependency to add. SHA-256 hex gives you a fixed-width, collision-resistant string to store and compare.

## Step 4 — Claim the key atomically

This is the heart of the system, and the part shallow tutorials get wrong. The naive approach — `SELECT` to check if the key exists, then `INSERT` if it doesn't — has a race: two concurrent retries both `SELECT` nothing, both `INSERT`, and you are back to a double charge.

The fix is to let Postgres serialize the claim with a single atomic statement. `INSERT ... ON CONFLICT (account_id, idempotency_key) DO NOTHING RETURNING` inserts the row if the key is new and returns it; if the key already exists, it returns zero rows[^oncon]. Exactly one concurrent request can win the insert.

First, a tiny query seam so the logic is testable against any Postgres-compatible driver:

```typescript
// src/db.ts
export interface QueryResult<R> {
  rows: R[];
}

export type QueryFn = <R>(text: string, params?: unknown[]) => Promise<QueryResult<R>>;
```

Now the claim and its companions:

```typescript
// src/idempotency.ts
import type { QueryFn } from './db.ts';

interface KeyRow {
  request_fingerprint: string;
  status: 'in_progress' | 'completed';
  response_code: number | null;
  response_body: unknown;
}

export type ClaimResult =
  | { kind: 'new' }
  | { kind: 'concurrent' }
  | { kind: 'mismatch' }
  | { kind: 'replay'; code: number; body: unknown };

export async function claimKey(
  query: QueryFn,
  accountId: string,
  key: string,
  fingerprint: string,
): Promise<ClaimResult> {
  const inserted = await query<{ account_id: string }>(
    `INSERT INTO idempotency_keys (account_id, idempotency_key, request_fingerprint)
     VALUES ($1, $2, $3)
     ON CONFLICT (account_id, idempotency_key) DO NOTHING
     RETURNING account_id`,
    [accountId, key, fingerprint],
  );
  if (inserted.rows.length === 1) return { kind: 'new' };

  const existing = await query<KeyRow>(
    `SELECT request_fingerprint, status, response_code, response_body
     FROM idempotency_keys
     WHERE account_id = $1 AND idempotency_key = $2`,
    [accountId, key],
  );
  const row = existing.rows[0];
  if (!row) return { kind: 'new' };
  if (row.request_fingerprint !== fingerprint) return { kind: 'mismatch' };
  if (row.status === 'in_progress') return { kind: 'concurrent' };
  return { kind: 'replay', code: row.response_code ?? 200, body: row.response_body };
}

export async function completeKey(
  query: QueryFn,
  accountId: string,
  key: string,
  code: number,
  body: unknown,
): Promise<void> {
  await query(
    `UPDATE idempotency_keys
     SET status = 'completed', response_code = $3, response_body = $4
     WHERE account_id = $1 AND idempotency_key = $2`,
    [accountId, key, code, JSON.stringify(body)],
  );
}

export async function releaseKey(query: QueryFn, accountId: string, key: string): Promise<void> {
  await query(
    `DELETE FROM idempotency_keys WHERE account_id = $1 AND idempotency_key = $2`,
    [accountId, key],
  );
}
```

Note the **order of the branches**: the fingerprint mismatch is checked *before* the `in_progress` status. A key reused with a different body is a `422` whether or not the first request has finished — the client made a mistake the server cannot reconcile. A genuine retry sends the same body, so its fingerprint matches and it falls through to the `409` or replay branches. `releaseKey` deletes the claim if the business operation throws, so the client can safely retry rather than getting a stuck `409` forever.

## Step 5 — Wire the Fastify preHandler

A `preHandler` hook runs after the body is parsed but before your route handler. It is the perfect seam to enforce idempotency for every mutating route at once.

```typescript
// src/app.ts
import Fastify, { type FastifyInstance } from 'fastify';
import type { QueryFn } from './db.ts';
import { requestFingerprint } from './fingerprint.ts';
import { claimKey, completeKey, releaseKey } from './idempotency.ts';

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };

declare module 'fastify' {
  interface FastifyRequest {
    idempotency?: { accountId: string; key: string };
  }
}

const problem = (type: string, title: string, detail: string) => ({
  type: `https://api.example.com/errors/${type}`,
  title,
  detail,
});

export function buildApp(query: QueryFn): FastifyInstance {
  const app = Fastify({ logger: false });

  app.addHook('preHandler', async (req, reply) => {
    if (req.method !== 'POST' && req.method !== 'PATCH') return;

    const key = req.headers['idempotency-key'];
    if (typeof key !== 'string' || key.length === 0) {
      return reply.code(400).type('application/problem+json').send(
        problem('idempotency-key-missing', 'Idempotency-Key is missing',
          'This operation requires a unique Idempotency-Key request header.'),
      );
    }

    const accountId = req.headers['x-account-id'];
    if (typeof accountId !== 'string' || accountId.length === 0) {
      return reply.code(401).send(problem('unauthorized', 'Unauthorized', 'Missing account context.'));
    }

    const fingerprint = requestFingerprint(req.method, req.url, (req.body ?? null) as Json);
    const result = await claimKey(query, accountId, key, fingerprint);

    switch (result.kind) {
      case 'new':
        req.idempotency = { accountId, key };
        return;
      case 'concurrent':
        return reply.code(409).type('application/problem+json').send(
          problem('idempotency-conflict', 'A request is outstanding for this Idempotency-Key',
            'A request with the same Idempotency-Key is still being processed.'),
        );
      case 'mismatch':
        return reply.code(422).type('application/problem+json').send(
          problem('idempotency-key-reused', 'Idempotency-Key is already used',
            'This Idempotency-Key was already used with a different request payload.'),
        );
      case 'replay':
        return reply.code(result.code).send(result.body);
    }
  });

  registerRoutes(app, query);
  return app;
}
```

The hook returns early for the `400`, `409`, `422`, and replay cases — the route handler never runs. Only the `new` case falls through, and it stashes `{ accountId, key }` on the request so the handler knows which row to complete. The error bodies use `application/problem+json` (RFC 9457, the successor to RFC 7807), exactly the shape the IETF draft shows[^ietf].

## Step 6 — The business route and marking the key complete

Now the route that actually does the work. After it succeeds, it flips the claimed row to `completed` and stores the response so future retries replay it. If the work throws, it releases the key.

```typescript
// add to src/app.ts
function registerRoutes(app: FastifyInstance, query: QueryFn): void {
  app.post('/v1/payments', async (req, reply) => {
    const body = (req.body ?? {}) as { amount?: number; currency?: string };

    try {
      // Your real work goes here: charge a card, insert a row, call a provider.
      const payment = {
        id: `pay_${Math.random().toString(36).slice(2, 10)}`,
        amount: body.amount ?? 0,
        currency: body.currency ?? 'usd',
        status: 'succeeded',
      };

      if (req.idempotency) {
        await completeKey(query, req.idempotency.accountId, req.idempotency.key, 201, payment);
      }
      return reply.code(201).send(payment);
    } catch (err) {
      if (req.idempotency) {
        await releaseKey(query, req.idempotency.accountId, req.idempotency.key);
      }
      throw err;
    }
  });
}
```

In a real service, the business write and the `completeKey` update belong in the **same database transaction**, so the payment row and the "this key is done" marker commit together or not at all. The two-function shape above keeps the example readable; wrap both calls in a `BEGIN`/`COMMIT` when you adapt it.

Finally, wire the production database and boot the server:

```typescript
// src/index.ts
import { Pool } from 'pg';
import type { QueryFn } from './db.ts';
import { buildApp } from './app.ts';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const query: QueryFn = async <R>(text: string, params: unknown[] = []) => {
  const result = await pool.query(text, params);
  return { rows: result.rows as R[] };
};

const app = buildApp(query);
const port = Number(process.env.PORT ?? 3000);

app.listen({ port, host: '0.0.0.0' })
  .then((address) => console.log(`listening on ${address}`))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });
```

Run it with Node's native loader — no build step:

```bash
DATABASE_URL=postgres://postgres:secret@localhost:5432/postgres \
  node --import tsx src/index.ts
```

## Verification

Open two terminals' worth of `curl`. First, a fresh request — note the response `id`:

```bash
curl -i -X POST http://localhost:3000/v1/payments \
  -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' \
  -H 'X-Account-Id: acct_123' \
  -H 'Content-Type: application/json' \
  -d '{"amount":5000,"currency":"usd"}'
```

```http
HTTP/1.1 201 Created
{"id":"pay_i64z416e","amount":5000,"currency":"usd","status":"succeeded"}
```

Run the **exact same command again**. You get `201` with the *same* `id` — the stored response is replayed, and no second payment is created:

```http
HTTP/1.1 201 Created
{"id":"pay_i64z416e","amount":5000,"currency":"usd","status":"succeeded"}
```

Now reuse the key with a **different body**. The fingerprint changes, so the server rejects it with `422`:

```bash
curl -i -X POST http://localhost:3000/v1/payments \
  -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' \
  -H 'X-Account-Id: acct_123' \
  -H 'Content-Type: application/json' \
  -d '{"amount":9999,"currency":"usd"}'
```

```http
HTTP/1.1 422 Unprocessable Content
{"type":"https://api.example.com/errors/idempotency-key-reused","title":"Idempotency-Key is already used", ...}
```

And a request with no `Idempotency-Key` header returns `400`. A retry that arrives while the first is still `in_progress` returns `409`. Those are the five outcomes the spec defines[^ietf], all driven by one table.

## The concurrency trade-off worth understanding

There are two honest ways to handle a retry that arrives *while the first request is still running*, and they behave differently.

**Commit the claim first** (what this guide does): the `in_progress` row is inserted and committed before the business work starts. A concurrent retry's `INSERT ... ON CONFLICT` sees a *committed* row, immediately gets zero rows back, and returns `409`. No locks are held during the slow work. This is the spec's recommended `409` behavior and it scales, but the client has to retry once more after the first finishes.

**One transaction** (the alternative): claim, do the work, and mark complete inside a single transaction. A concurrent retry's `INSERT ... ON CONFLICT` now hits an *uncommitted* row and **blocks** until the first transaction commits, then sees the conflict and replays the finished response — the client never sees a `409`. Nicer for the client, but it ties up a connection and a row lock for the full duration of the operation, which is dangerous if the work is slow or calls a third party.

Pick the first for high-throughput public APIs; pick the second only when operations are short and a transparent wait is worth more than throughput.

## Expire and clean up old keys

Stored keys are not free — left alone, the table grows forever. The `expires_at` column gives each row a 24-hour life. Reap expired rows on a schedule:

```sql
DELETE FROM idempotency_keys WHERE expires_at < now();
```

Run it from your app on an interval, or push it into the database itself. If you already keep cron logic in Postgres, you can [automate it with pg_cron](https://nerdleveltech.com/pg-partman-pg-cron-postgres-18-partition-automation-tutorial):

```sql
SELECT cron.schedule(
  'reap-idempotency-keys',
  '*/15 * * * *',
  $$DELETE FROM idempotency_keys WHERE expires_at < now()$$
);
```

A row stuck in `in_progress` because the server crashed mid-request is handled by the same expiry sweep, and the `locked_at` timestamp lets you reclaim such rows sooner — treat an `in_progress` row older than, say, 60 seconds as abandoned and overwrite it on the next retry.

## Troubleshooting

- **Two concurrent requests both create a resource.** You are checking with `SELECT` then `INSERT` instead of a single `INSERT ... ON CONFLICT`. The read-then-write gap is the race. Let the unique constraint serialize the claim.
- **A retry returns `422` even though the client sent the same data.** Your fingerprint depends on JSON key order or on a volatile field (a timestamp, a client-generated nonce inside the body). Hash a stable serialization and exclude fields that legitimately change between retries.
- **Keys never expire and the table balloons.** The cleanup job is not running, or `expires_at` was never set because you inserted rows with a column list that skipped the default. Confirm with `SELECT min(expires_at), count(*) FROM idempotency_keys`.
- **Clients get a permanent `409` for a key.** The first request crashed after claiming but before completing or releasing, leaving a stuck `in_progress` row. Add the `locked_at` reclaim rule above so abandoned claims time out.
- **`error: column "response_body" is of type jsonb but expression is of type text`.** Pass the body to the driver as a JSON string (`JSON.stringify(body)`) and let the `jsonb` column parse it, or cast with `$4::jsonb`.

## Next steps and further reading

You now have a Postgres-backed idempotency layer that turns unreliable retries into safe ones. From here:

- Move the `completeKey` update into the same transaction as your business write so they commit atomically.
- Pair this with [sliding-window rate limiting for a Node API](https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial) to harden the same endpoints against abuse.
- For event pipelines, the same "process exactly once" idea appears at the message layer — see how to [deduplicate messages in a NATS JetStream worker](https://nerdleveltech.com/nats-jetstream-typescript-durable-worker-retries-dlq-tutorial).

## Related reads

- [Postgres LISTEN/NOTIFY for Job Queues: 2026 Guide](https://nerdleveltech.com/postgres-listen-notify-job-queue)

[^ietf]: J. Jena and S. Dalal, "The Idempotency-Key HTTP Header Field," draft-ietf-httpapi-idempotency-key-header-07, IETF, 15 October 2025. https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07
[^rfc9110]: R. Fielding et al., "HTTP Semantics," RFC 9110, June 2022 (method idempotency in §9.2.2; `422 Unprocessable Content` in §15.5.21). https://www.rfc-editor.org/rfc/rfc9110
[^stripe]: Stripe, "Idempotent requests." https://docs.stripe.com/api/idempotent_requests
[^brandur]: B. Leach, "Implementing Stripe-like Idempotency Keys in Postgres." https://brandur.org/idempotency-keys
[^fastify]: Fastify, npm package `fastify` 5.8.5; v5 targets Node.js 20 and above. https://www.npmjs.com/package/fastify
[^pg]: node-postgres (`pg`) 8.21.0. https://www.npmjs.com/package/pg
[^pg18]: PostgreSQL Global Development Group, "PostgreSQL 18.4, 17.10, 16.14, 15.18, and 14.23 Released!" 14 May 2026. https://www.postgresql.org/about/news/postgresql-184-1710-1614-1518-and-1423-released-3297/
[^oncon]: PostgreSQL documentation, "INSERT — ON CONFLICT clause." https://www.postgresql.org/docs/18/sql-insert.html
[^node]: Node.js Release Working Group, "Release schedule" (Node 24 Active LTS through April 2028). https://github.com/nodejs/release
