ID

Home·Writings

How I Built Idempotent Payments with Redis and PostgreSQL

Stop double charges when mobile clients retry: an Idempotency-Key header, Postgres as the source of truth, and Redis as the fast path.

A customer on a weak 3G connection taps Send ₦50,000. The request reaches the server, the transfer goes out, and the response dies somewhere between the cell tower and the phone. The app shows a spinner, times out, and the customer taps again.

Without protection, that second tap is a second transfer. On every payments system I have worked on, this is the bug that gets the angriest support tickets, because it is the customer's money and it happened "on its own".

The fix is idempotency: the same request, sent any number of times, has the effect of sending it once. This post walks through the version I use, with a fictional digital bank, Ledgerline, as the example.

The contract: an Idempotency-Key per user action

The client generates a UUID when the user starts an action (opening the confirm screen), and sends it on every attempt of that action:

POST /v1/transfers
Idempotency-Key: 6f1c2a4e-8d0b-4c1e-9a7f-2b3c4d5e6f70
Content-Type: application/json

{ "toAccount": "0123456789", "amount": 5000000, "currency": "NGN" }

Two rules make this work:

  • One key per intent, not per HTTP call. Retries reuse the key. A genuinely new transfer gets a new key.
  • The key is scoped to the user. User A's key can never replay User B's response.

The server's job: the first time it sees (userId, key), do the work and remember the result. Every later time, return the remembered result without doing the work again.

Postgres is the source of truth

Redis is fast, but it can be flushed, failed over, or evicted. Money correctness cannot depend on it. So the real guarantee lives in a table with a unique constraint:

CREATE TABLE idempotency_keys (
  user_id        uuid        NOT NULL,
  key            text        NOT NULL,
  request_hash   text        NOT NULL,
  status         text        NOT NULL DEFAULT 'in_progress', -- in_progress | completed | failed
  response_code  int,
  response_body  jsonb,
  created_at     timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, key)
);

request_hash is a SHA-256 of the method, path and body. It catches a nasty client bug: reusing a key with a different body. That must be rejected, not silently answered with the old response.

The flow

async createTransfer(userId: string, key: string, dto: CreateTransferDto) {
  const hash = sha256(`POST:/v1/transfers:${stableStringify(dto)}`);

  // 1. Claim the key. Only one request can win the insert.
  const claimed = await this.db.query(
    `INSERT INTO idempotency_keys (user_id, key, request_hash)
     VALUES ($1, $2, $3)
     ON CONFLICT (user_id, key) DO NOTHING
     RETURNING key`,
    [userId, key, hash],
  );

  if (claimed.rowCount === 0) {
    return this.replay(userId, key, hash);
  }

  // 2. We own the key. Do the work.
  try {
    const transfer = await this.transfers.execute(userId, dto);
    await this.complete(userId, key, 201, transfer);
    return transfer;
  } catch (err) {
    if (isRetryable(err)) {
      // Free the key so the client's retry can run again.
      await this.release(userId, key);
    } else {
      await this.complete(userId, key, errorStatus(err), errorBody(err), 'failed');
    }
    throw err;
  }
}

And the replay path:

private async replay(userId: string, key: string, hash: string) {
  const row = await this.findKey(userId, key);

  if (row.request_hash !== hash) {
    throw new UnprocessableEntityException('Idempotency-Key reused with a different request');
  }
  if (row.status === 'in_progress') {
    // The first attempt is still running. Tell the client to retry shortly.
    throw new ConflictException('Request with this Idempotency-Key is still processing');
  }
  return this.respondWith(row.response_code, row.response_body);
}

The INSERT ... ON CONFLICT DO NOTHING is the whole trick. Two identical requests arriving in the same millisecond on two different servers still cannot both win, because the primary key is enforced by the database.

Where Redis fits

Most replays happen within seconds of the original. Hitting Postgres for each one is fine at low volume, but on a busy day it is wasted load. So I put Redis in front as a cache of finished responses, never as the lock of record:

const cached = await this.redis.get(`idem:${userId}:${key}`);
if (cached) {
  const row = JSON.parse(cached);
  if (row.request_hash === hash) return this.respondWith(row.response_code, row.response_body);
}
// fall through to the Postgres flow above

When a request completes, write the response to both places, with a TTL in Redis (24 hours is typical). If Redis is down, the system is slower, not wrong. That is the property you want for anything touching money.

Failure modes worth designing for

The server crashes mid-transfer. The row is stuck at in_progress. Add a locked_until timestamp, and let a later request take over a stale claim. Before redoing the work, check the downstream system (the core banking API or payment provider) using the transfer's own reference. This is why the transfer itself must carry a stable reference derived from the key, so the provider can deduplicate too.

The downstream provider times out. You don't know whether the money moved. Do not release the key and let the client retry blindly. Mark it in_progress, and let a reconciliation job query the provider's status endpoint and finish the record. A retry with backoff from the client will then get the real answer.

Validation errors. A 400 for "insufficient balance" should be stored and replayed. The client sent the same request; it gets the same answer. Only transient errors (timeouts, 503s) release the key.

Keys forever. Keep rows for as long as a client could plausibly retry, plus your audit window. A nightly job that deletes completed keys older than 30 days keeps the table small. The transfer records themselves are kept forever in the ledger, so nothing is lost.

Make the client do its part

The server can only deduplicate what it can recognise. In the mobile app:

  • Generate the key when the confirm screen opens, keep it in state, and clear it only on a definite success or a definite business failure.
  • Retry with exponential backoff and jitter, reusing the key.
  • Treat 409 still processing as "wait and retry", not as an error to show the user.

Takeaways

PieceJob
Idempotency-Key headerIdentifies one user intent across retries
Postgres unique constraintThe actual guarantee; survives crashes and failovers
request_hashRejects key reuse with a different body
RedisFast replay of finished responses; safe to lose
Stable provider referenceLets the downstream system deduplicate too
Reconciliation jobResolves "we don't know if it went through"

Idempotency is not a library you add at the end. It is a contract between the app, the API and every system the money passes through. Get the database constraint right first; everything else is optimisation. It belongs on the same launch checklist as the other invariants that separate a wired API from a ready product.