Saikat
← Back to blog

Transactions & Idempotency: How Not to Double-Charge Your Users

July 19, 2026·9 min read
PaymentsPostgreSQLIdempotencyBackend
Share:
Transactions & Idempotency: How Not to Double-Charge Your Users

The scariest bug report a payments backend can receive is four words long: "I was charged twice." It's scary because by the time you read it, the money has already moved — twice — and no amount of clever code can un-ring that bell. You're now in refund-and-apologize territory, and if it happened to one user, it happened to more.

I've built payment integrations against Stripe, PayPal, and bKash, and the uncomfortable truth is that none of those guides matter much if the layer around the gateway call isn't safe. The gateway is rarely the thing that double-charges. Your own retry logic is. This post is about the machinery that makes a payment flow safe to retry: idempotency keys, database transactions, the outbox pattern, and what to do when a gateway call times out and you genuinely don't know what happened.

Why double-charges actually happen

Every double-charge I've debugged traces back to one of four causes, and none of them is exotic:

  • Client retries. A user on a slow connection taps "Pay", sees a spinner for eight seconds, and taps again. Or your frontend's HTTP client has retries: 3 configured globally and a POST /charge times out. Same request, sent twice, both succeed.
  • Timeouts with unknown outcomes. Your server calls the gateway, the gateway processes the charge, but the response gets lost — the socket times out on your side. Your code sees an error. Was the user charged? You have no idea. If you blindly retry, you might charge them again.
  • Double-clicks and duplicate form submissions. The oldest bug on the web. Disabling the button after the first click helps, but it's a UX courtesy, not a guarantee — the backend must be the enforcement point.
  • Webhook redelivery. Every serious gateway delivers webhooks at least once, not exactly once. Stripe will happily resend payment_intent.succeeded if your endpoint was slow to respond. If your handler credits the user's wallet on every delivery, redelivery means double-crediting.

Notice the pattern: the network is unreliable, so something will always retry — the user, the HTTP client, or the gateway. You can't prevent duplicate requests. You can only make duplicates harmless. That property has a name: idempotency.

Idempotency keys: making retries safe

An idempotency key is a unique identifier for an operation, generated by the client and sent with the request. The server's contract is: for a given key, execute the operation at most once, and return the same response for every subsequent request carrying that key.

Three rules make this work in practice:

  1. The client generates the key (a UUID per checkout attempt, created when the payment form renders — not per HTTP request). A retry of the same attempt reuses the key; a genuinely new purchase gets a new one.
  2. The server stores the key with the response, so replays can be answered without re-executing anything.
  3. A unique constraint enforces it. Application-level "check if key exists, then insert" has a race window between the check and the insert. Two concurrent requests both pass the check. The database's unique index is the only referee that can't be raced.

Here's the shape I use with TypeORM and Postgres:

@Entity('idempotency_keys')
@Unique(['key'])
export class IdempotencyKey {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  key: string;

  @Column({ type: 'enum', enum: ['processing', 'completed'] })
  status: 'processing' | 'completed';

  @Column({ type: 'int', nullable: true })
  responseStatus: number | null;

  @Column({ type: 'jsonb', nullable: true })
  responseBody: Record<string, unknown> | null;

  @CreateDateColumn()
  createdAt: Date;
}

And the service logic, where the INSERT itself is the lock acquisition:

async charge(idempotencyKey: string, dto: ChargeDto) {
  try {
    await this.keysRepo.insert({ key: idempotencyKey, status: 'processing' });
  } catch (e) {
    if (isUniqueViolation(e)) {
      const existing = await this.keysRepo.findOneBy({ key: idempotencyKey });
      if (existing.status === 'completed') {
        // Replay: return the stored response, execute nothing.
        return { status: existing.responseStatus, body: existing.responseBody };
      }
      // First request still in flight — tell the client to wait, not retry.
      throw new ConflictException('Request already processing');
    }
    throw e;
  }

  const result = await this.executeCharge(dto);

  await this.keysRepo.update(
    { key: idempotencyKey },
    { status: 'completed', responseStatus: 201, responseBody: result },
  );
  return { status: 201, body: result };
}

The processing state matters. Without it, a duplicate arriving while the first request is mid-flight would either execute again or return an empty response. Returning 409 for in-flight duplicates tells well-behaved clients to poll rather than re-execute. Pass the same key through to the gateway too — Stripe's Idempotency-Key header gives you a second line of defense at their end.

One operational note: expire old keys. Stripe keeps theirs for 24 hours; I do the same with a nightly delete. An idempotency table that grows forever eventually becomes its own incident.

Transactions and isolation: the write side

Idempotency keys stop duplicate operations. Database transactions stop partial ones. A payment write is never one row — it's an order status update, a payment record, a ledger entry, maybe a stock decrement. Either all of them happen or none of them do:

await this.dataSource.transaction(async (manager) => {
  const order = await manager.findOne(Order, {
    where: { id: orderId },
    lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
  });

  if (order.status === 'paid') return; // idempotent no-op on replay

  order.status = 'paid';
  await manager.save(order);
  await manager.insert(Payment, { orderId, amount: order.total, gatewayRef });
  await manager.insert(LedgerEntry, { account: 'revenue', credit: order.total });
});

The pessimistic_write lock is doing real work here. Postgres's default isolation level is READ COMMITTED, which permits two concurrent transactions to both read status = 'pending' and both proceed to mark the order paid. SELECT ... FOR UPDATE serializes them: the second transaction blocks until the first commits, then re-reads the row, sees paid, and no-ops.

You could get the same safety from SERIALIZABLE isolation instead of explicit locks, but then every transaction needs retry-on-serialization-failure logic. Here's how I think about the options for payment writes:

Isolation level Stops double-spend on same row? Cost When I use it
READ COMMITTED (default) No — lost updates possible Cheapest Only with explicit FOR UPDATE locks
REPEATABLE READ Partially — write conflicts abort Moderate, must handle aborts Multi-row consistency checks
SERIALIZABLE Yes Highest, retry loops required Complex invariants across many rows
READ COMMITTED + SELECT FOR UPDATE Yes, for the locked rows Cheap, explicit Payment writes — my default

Explicit row locks on the order are boring, targeted, and easy to reason about. That's exactly what I want near money.

The outbox pattern: reliable event emission

Payments rarely end at the database. A successful charge needs to emit events — send a receipt email, notify the fulfillment service, push to analytics. The naive version commits the transaction and then publishes to a queue:

await this.dataSource.transaction(async (manager) => { /* ...writes... */ });
await this.eventBus.publish('payment.succeeded', payload); // 💥 crash here = lost event

If the process dies between commit and publish, the payment exists but downstream never hears about it. Flip the order and you get the opposite failure: an event for a payment that rolled back. The two systems can't be updated atomically — unless the event is a database write.

That's the outbox pattern. Inside the same transaction as the payment, insert the event into an outbox table:

await this.dataSource.transaction(async (manager) => {
  // ... payment writes ...
  await manager.insert(Outbox, {
    topic: 'payment.succeeded',
    payload: { orderId, amount },
    status: 'pending',
  });
});

A separate relay — a NestJS @Interval worker is plenty at moderate scale — polls the table and publishes:

SELECT * FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 50
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED lets multiple relay instances drain the table without stepping on each other. The guarantee becomes: if the payment committed, the event will be published — at least once. Which circles back to the start of this post: consumers must be idempotent, because redelivery is now part of your own architecture, not just the gateway's. In a microservices setup, I treat the pair — outbox on the producer, idempotent handler on the consumer — as a single non-negotiable unit.

Gateway timeouts: query before retry

The hardest failure mode is the ambiguous one. You called the gateway, the request timed out, and the charge is in a superposition: maybe it happened, maybe not. The rule that keeps you safe is simple: never blindly retry a timed-out charge. Query first.

async chargeWithRecovery(dto: ChargeDto, merchantRef: string) {
  try {
    return await this.gateway.createCharge({ ...dto, merchantRef });
  } catch (err) {
    if (!isTimeout(err)) throw err;

    // Ambiguous outcome — ask the gateway what actually happened.
    const existing = await this.gateway.findChargeByRef(merchantRef);
    if (existing?.status === 'succeeded') return existing; // it went through
    if (existing?.status === 'pending') return existing;   // let webhooks settle it

    return await this.gateway.createCharge({ ...dto, merchantRef }); // truly never landed
  }
}

Every gateway I've integrated supports this lookup, though they name it differently — Stripe lets you search PaymentIntents by metadata (or just reuse the idempotency key, which makes the retry itself safe), PayPal has order lookup by ID, and bKash's searchTransaction API exists for exactly this scenario. The merchantRef — your own ID attached to the charge at creation time — is what makes the recovery query possible, so attach one to every gateway call from day one.

And for webhook redelivery, the last piece: record every processed event ID in a table with a unique constraint, same trick as the idempotency keys. Insert the event ID, and if the insert violates the constraint, acknowledge with a 200 and do nothing. The gateway stops retrying, and your wallet credits stay single.

Wrapping up

Nothing in this post is glamorous. It's unique indexes, row locks, an extra table, and a lookup call before a retry. But payments are the one domain where "it works 99.9% of the time" is a complaint generator, because the 0.1% is someone's money.

The mental model that ties it together: assume every request will be delivered twice, every response will occasionally be lost, and every process will someday die at the worst possible line. Idempotency keys make duplicate requests harmless. Transactions with row locks make concurrent writes safe. The outbox makes event emission survive crashes. Query-before-retry makes timeouts recoverable. Build those four in from the start — retrofitting them after the first "I was charged twice" email is a much worse week.