backend

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

June 6, 2026

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.51, node-postgres 8.21.02, and Postgres 18.43 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 out4: 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 LTS5. Node 24 runs .ts files directly, so there is no build step in dev.
  • Docker with the official postgres:18.4 image3.
  • 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 not6. 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 recommended4 — and sends it with every retry of the same logical operation:

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 String4, but most production APIs — Stripe among them7 — 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 header4, and the pattern was popularized by Stripe's engineering write-ups7 and Brandur Leach's canonical Postgres design8.

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

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.

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:

{
  "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:

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.

-- 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 responses4. 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 4224. We compute it next.

Apply the schema:

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.

// 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 rows9. Exactly one concurrent request can win the insert.

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

// 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:

// 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.

// 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 shows4.

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.

// 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:

// 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:

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:

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/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/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:

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/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 defines4, 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:

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:

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:

Footnotes

  1. Fastify, npm package fastify 5.8.5; v5 targets Node.js 20 and above. https://www.npmjs.com/package/fastify

  2. node-postgres (pg) 8.21.0. https://www.npmjs.com/package/pg

  3. 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/ 2

  4. 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 2 3 4 5 6 7 8

  5. Node.js Release Working Group, "Release schedule" (Node 24 Active LTS through April 2028). https://github.com/nodejs/release

  6. 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

  7. Stripe, "Idempotent requests." https://docs.stripe.com/api/idempotent_requests 2

  8. B. Leach, "Implementing Stripe-like Idempotency Keys in Postgres." https://brandur.org/idempotency-keys

  9. PostgreSQL documentation, "INSERT — ON CONFLICT clause." https://www.postgresql.org/docs/18/sql-insert.html