Mahbubul Alam.
Payments

Idempotent Payment Processing: Lessons from Integrating bKash, SSLCommerz, and Stripe

Aug 20263 min read
Idempotent Payment Processing: Lessons from Integrating bKash, SSLCommerz, and Stripe

The shared problem across every gateway

Every payment gateway has moments of unreliability — a timeout on the confirmation call, a slow response that makes the client retry, a network blip between your server and theirs. The dangerous version of this isn't the failure itself, it's the retry: if a client (or your own retry logic) resubmits a payment request after a timeout, and the original request actually succeeded server-side, a naive integration charges the customer twice. Across bKash, SSLCommerz, and Stripe — three gateways with genuinely different APIs, webhook formats, and regional reliability characteristics — the fix was the same pattern each time.

Idempotency keys, generated before the network call

// Generated client-side, once, at "place order" — not regenerated on retry
const idempotencyKey = crypto.randomUUID();

async function chargeCard(orderId, amount, idempotencyKey) {
  const existing = await PaymentAttempt.findOne({ idempotencyKey });
  if (existing) return existing.result; // already handled, return the same outcome

  const attempt = await PaymentAttempt.create({ idempotencyKey, orderId, status: "processing" });
  const result = await gateway.charge({ amount, idempotencyKey });
  attempt.status = result.status;
  attempt.result = result;
  await attempt.save();
  return result;
}

The key insight is that the idempotency key is generated once and reused across retries — not regenerated each attempt. Stripe accepts an idempotency key natively in its API; for gateways that don't (bKash, SSLCommerz didn't at the time), the same guarantee has to be built at the application layer, checking for an existing attempt before ever calling the gateway.

Where the three gateways actually differed

  • Stripe has first-class idempotency key support baked into the API itself — pass the same key on a retried request and Stripe returns the original result without charging again. The easiest of the three to get right.
  • SSLCommerz confirms payment via a server-to-server IPN (instant payment notification) callback rather than a synchronous API response, which meant the "did this succeed" answer sometimes arrived after the user had already been redirected back — the order had to start in a "processing" state and only flip to "paid" once the IPN was verified and processed.
  • bKash's mobile-money flow involves the user confirming on their phone outside the browser entirely, so the window between "payment initiated" and "payment confirmed" is genuinely unpredictable in length — the UI had to be built around polling or websocket updates rather than assuming a fast, synchronous confirmation.

The reconciliation job as insurance

Even with idempotency keys and webhook verification correctly implemented, I run a scheduled reconciliation job against each gateway's transaction API for any order stuck in "processing" past a reasonable threshold. This isn't redundant — it's the backstop for the failure modes idempotency doesn't cover, like a webhook that's silently dropped by a network issue on the gateway's side, which no amount of correct code on your end prevents.

The takeaway

Different gateways, different APIs, different regional reliability — but the underlying discipline is identical: generate the idempotency key once, before the first network call; check for an existing attempt before making a new one; and never let a client-side redirect be the thing that marks money as received.

#payments#idempotency#fintech