Back to Blog
Node.js

How to Implement Idempotency Keys in Payment Gateways

A hands-on Node.js guide to payment idempotency keys—why retries double-charge, how to store and replay safe responses, and how to prove the pattern with tests.

How to implement idempotency keys in payment gateways: a retry request returns the same stored payment result instead of creating a duplicate charge.

I am Sajan Acharya, a Senior Software Engineer based in Kathmandu. If a customer taps Pay once, the network flakes, and your app retries—did they get charged once or twice? That question is why idempotency keys exist. An idempotency key is a unique string attached to a money-moving request so your backend can say: “I already did this work; here is the same result again.” Without that contract, payment gateways and impatient clients combine into double captures, refund tickets, and nights in Stripe or eSewa dashboards.

This guide shows how to implement idempotency keys in payment gateways on Node.js, with terminal and code examples you can steal. Pair prevention with how to implement payment reconciliation so late webhooks still get caught, and see eSewa, Khalti, and Fonepay integration in Node.js for Nepal-specific gateways. For production payment APIs, see Node.js developer services.

Why you need this (the double-charge failure)

Here is the classic bug. Request A reaches the gateway and captures NPR 5,000. Your server times out before reading the response. The mobile app retries. Request B has no memory of A, so it creates a second charge. Your database may show one pending row—or two. The customer sees two deductions.

# Without an idempotency key — two charges for one user click
$ curl -i -X POST http://localhost:3000/v1/payments \
  -H "Content-Type: application/json" \
  -d '{"orderId":"ORD-1842","amountMinor":500000,"currency":"NPR"}'

HTTP/1.1 201 Created
{"paymentId":"pay_aaa","status":"paid","amountMinor":500000}
# (response never arrives — client times out)

$ curl -i -X POST http://localhost:3000/v1/payments \
  -H "Content-Type: application/json" \
  -d '{"orderId":"ORD-1842","amountMinor":500000,"currency":"NPR"}'

HTTP/1.1 201 Created
{"paymentId":"pay_bbb","status":"paid","amountMinor":500000}
# Gateway now has pay_aaa AND pay_bbb. Customer is charged twice.

With a shared Idempotency-Key, the second call must return the first result—or a clear “still processing” response—never a new capture.

# Same user action, same key, safe retry
$ KEY=3f7a8c2e-9b1d-4e7f-a3c9-2b1e9f7d8c6a

$ curl -i -X POST http://localhost:3000/v1/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"orderId":"ORD-1842","amountMinor":500000,"currency":"NPR"}'

HTTP/1.1 201 Created
{"paymentId":"pay_aaa","status":"paid","amountMinor":500000}

# Network blip — client retries with THE SAME key
$ curl -i -X POST http://localhost:3000/v1/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"orderId":"ORD-1842","amountMinor":500000,"currency":"NPR"}'

HTTP/1.1 201 Created
{"paymentId":"pay_aaa","status":"paid","amountMinor":500000}
# Replay. No second gateway charge.

What an idempotency key actually guarantees

A key is an opaque string—usually a UUID—scoped to one business action, such as “create payment for order ORD-1842.” First request with that key performs the side effect and stores the outcome. Later requests with the same key and the same payload return that outcome. If the payload changes under the same key, reject with 422: that is a client bug, not a retry.

  • Same key + same body → same stored response (no second charge)
  • Same key + different body → 422 Unprocessable Entity
  • Missing key on money endpoints → 400 in production
  • New user click / new checkout → new key
  • Keep keys 24–72 hours (longer if support needs history)

Generate keys on the right side of the wire

Create the key before the first attempt and reuse it on every retry of that click. Browser refresh that starts a brand-new checkout gets a new key. Axios retry of the same click keeps the same key. For background jobs, mint the key when you enqueue the job and store it on the job row.

// Client / BFF — mint once per Pay click
import { randomUUID } from "crypto";

async function payOrder(orderId: string, amountMinor: number) {
  const idempotencyKey = randomUUID(); // store in component state / job row

  return fetch("/v1/payments", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ orderId, amountMinor, currency: "NPR" }),
  });
}

// Retry helper: NEVER mint a new key inside the retry loop
async function payWithRetry(orderId: string, amountMinor: number) {
  const key = randomUUID();
  for (let attempt = 1; attempt <= 3; attempt++) {
    const res = await fetch("/v1/payments", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": key, // same every attempt
      },
      body: JSON.stringify({ orderId, amountMinor, currency: "NPR" }),
    });
    if (res.status !== 409) return res; // 409 = still processing
    await sleep(500 * attempt);
  }
}

Server storage that survives concurrent retries

Redis locks alone are not enough—locks expire, payment results must not. Use a durable store with a unique constraint on (tenant_id, idempotency_key). Try to insert a row in state processing. If you win, call the gateway and save the response. If the insert conflicts, return the stored result or 409 while still processing. That compare-and-set pattern beats check-then-act races.

-- PostgreSQL sketch
CREATE TABLE payment_idempotency (
  tenant_id        text NOT NULL,
  idempotency_key  text NOT NULL,
  request_hash     text NOT NULL,
  status           text NOT NULL, -- processing | succeeded | failed
  response_code    int,
  response_body    jsonb,
  provider_payment_id text,
  created_at       timestamptz NOT NULL DEFAULT now(),
  updated_at       timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, idempotency_key)
);
// src/payments/createPayment.ts
import { createHash } from "crypto";

function hashBody(body: unknown) {
  return createHash("sha256").update(JSON.stringify(body)).digest("hex");
}

export async function createPayment(input: {
  tenantId: string;
  key: string;
  body: { orderId: string; amountMinor: number; currency: string };
}) {
  const requestHash = hashBody(input.body);

  try {
    await db.paymentIdempotency.insert({
      tenantId: input.tenantId,
      idempotencyKey: input.key,
      requestHash,
      status: "processing",
    });
  } catch (err) {
    if (!isUniqueViolation(err)) throw err;

    const existing = await db.paymentIdempotency.findOne({
      tenantId: input.tenantId,
      idempotencyKey: input.key,
    });

    if (existing.requestHash !== requestHash) {
      return { status: 422, body: { error: "idempotency_key_reuse_mismatch" } };
    }
    if (existing.status === "processing") {
      return { status: 409, body: { error: "payment_in_progress" } };
    }
    return { status: existing.responseCode, body: existing.responseBody };
  }

  // We own the key — call the gateway (pass the same key if PSP supports it)
  const charge = await gateway.charge({
    ...input.body,
    idempotencyKey: input.key,
  });

  const response = {
    paymentId: charge.id,
    status: "paid",
    amountMinor: input.body.amountMinor,
  };

  await db.paymentIdempotency.update({
    where: { tenantId: input.tenantId, idempotencyKey: input.key },
    data: {
      status: "succeeded",
      responseCode: 201,
      responseBody: response,
      providerPaymentId: charge.id,
    },
  });

  return { status: 201, body: response };
}

Keep provider payment ID, amount, currency, and error codes on the record so support can explain outcomes without guessing. Put this behind a clear payments module so checkout handlers stay thin as the state machine grows.

Gateway timeouts and webhook dedupe

Pass your key to the PSP when they support Idempotency-Key—you want protection on both sides. Still keep your own row: your process can timeout after their capture succeeds. On timeout, do not mint a new key. Re-query by key or provider reference, or wait for the webhook. Marking local state failed too early while the charge succeeded is how silent double-attempts start.

// Webhooks must be idempotent too
async function onChargeSucceeded(event: {
  id: string;
  data: { paymentId: string };
}) {
  const inserted = await db.webhookEvents.tryInsert({ eventId: event.id });
  if (!inserted) {
    // duplicate delivery — ignore
    return;
  }

  await db.payments.markPaid({ providerId: event.data.paymentId });
}

$ # Simulate duplicate webhook deliveries
$ pnpm test:webhooks --event evt_123 --times 5
[ok] handler ran side effects once
[ok] payment status paid exactly once

Treat webhooks as fast UX signals. Use payment reconciliation as the financial close when settlement disagrees with your ledger.

Tests that prove you cannot double-charge

If you only read about idempotency, you will still ship the bug. Automate the races.

$ pnpm test:idempotency

✓ concurrent same key → one gateway.charge() call, identical bodies
✓ same key + different amount → 422
✓ timeout after gateway success → retry returns stored 201
✓ missing Idempotency-Key on POST /v1/payments → 400
✓ webhook evt_1 delivered 5x → markPaid once
// Sketch: concurrent retry test
it("charges the gateway once for concurrent identical keys", async () => {
  const key = "test-key-001";
  const body = { orderId: "ORD-1", amountMinor: 500000, currency: "NPR" };

  const [a, b] = await Promise.all([
    createPayment({ tenantId: "t1", key, body }),
    createPayment({ tenantId: "t1", key, body }),
  ]);

  expect(gateway.charge).toHaveBeenCalledTimes(1);
  expect(a.body.paymentId).toEqual(b.body.paymentId);
});

In production, alert when keys stay in processing longer than your gateway SLA, and when 422 reuse-mismatch spikes—both mean client or worker bugs. Document for frontend and mobile: which endpoints require keys, how long to retry, and when to mint a new key.

Ship the safety net, then prove it

Idempotency keys are how you teach your payment API to survive retries. Leave this page with a concrete checklist: mint one key per user action, persist processing → succeeded/failed under a unique constraint, replay stored responses, reject key reuse with a new body, and dedupe webhooks by event ID. That is the difference between a tutorial CRUD charge and a payment system you can trust.

From Kathmandu I implement payment APIs for teams who cannot afford “we think it charged once.” If checkout still relies on hope after timeouts, get in touch with your gateway, retry behavior, and where keys live today. We can add durable idempotency, webhook dedupe, and monitoring—then connect the same ledger to reconciliation.