Here's a scenario that doesn't show up in any payment gateway's documentation: a customer pays, the gateway redirects them back to https://yourapp.com/payment/callback?paymentID=TR0011...&status=success, and at that exact moment your server is restarting — a deploy rolled out, the container got OOM-killed, the process crashed on an unrelated bug. The browser gets a 502, a timeout, or a blank white screen. The customer paid. Nobody told your app.
They refresh. Maybe your server is back up in eleven seconds. Maybe it's still down. Either way, the query string with paymentID is one refresh away from being gone forever, and now you have a paying customer staring at an error page, wondering if they just lost their money.
I've built payment flows for bKash, Stripe, and PayPal, and this specific failure — server down at the instant of redirect — is the one people forget to design for. They handle double-charges with idempotency keys, they handle webhook redelivery, but the redirect landing on a dead server is treated as "well, that's unlucky" instead of a case to design around. It shouldn't be. It's predictable, it's testable, and it has a clean solution.
Why this is a different problem than a failed payment
It's tempting to lump this into generic error handling, but it's structurally different from "the payment failed":
- The money already moved (or didn't) on the gateway's side, independent of your server. Your server being down doesn't undo the transaction — bKash, Stripe, and PayPal all process the charge themselves. Your callback endpoint is a notification, not a step in the payment itself.
- The only evidence the browser has is the URL.
paymentID,status,trxID— whatever the gateway put in the query string exists in exactly one place: the address bar of a tab the user might close, refresh, or lose in three seconds. - A refresh replays the request, but a closed tab doesn't. If the user hits refresh, your dead server (now back up) still sees
?paymentID=...in the URL. But if they give up, close the tab, or the browser was configured to strip query params on some redirect chain — that context is gone unless you captured it somewhere durable.
So the real question isn't "how do I handle payment failure" — it's "how do I make sure a successful payment is never orphaned just because my server had a bad five seconds."
The core principle: the redirect is not the source of truth
The single most important mental shift: the callback URL the user lands on tells you nothing trustworthy, whether your server is up or down. Even on a good day, a redirect can be spoofed (?status=success typed by hand) or replayed. The gateway's own API — "give me the current status of paymentID X" — is the only source of truth. Everything else in this post is about making sure you can reach that source of truth even when your server was unavailable at the worst possible moment.
That reframes the problem: you don't need your server to catch the callback request. You need the client to remember enough to ask "what actually happened to my payment?" as soon as your server is reachable again — whether that's in 200ms or after a page reload five minutes later.
The pattern: persist before you leave, verify when you're back
Three pieces, in order.
1. Persist payment context to localStorage before redirecting to the gateway
Before you ever send the user to bKash's or Stripe's hosted page, write the context you'll need to resume, to localStorage (not sessionStorage — a crashed server plus a browser crash-recovery reopen should still work, and sessionStorage dies with the tab):
// Right before redirecting to the gateway's hosted checkout page
function beginCheckout(orderId, gatewayPaymentId) {
const pendingPayment = {
orderId,
gatewayPaymentId,
startedAt: Date.now(),
status: 'redirected_to_gateway',
};
localStorage.setItem('pendingPayment', JSON.stringify(pendingPayment));
window.location.href = gatewayCheckoutUrl;
}
This is the insurance policy. Whatever happens next — successful callback, dead server, closed tab reopened an hour later — orderId and gatewayPaymentId survive in the browser, independent of any query string.
2. On the callback page, don't trust the URL — reconcile with localStorage and the server
The page the gateway redirects to should do the least amount of trusting possible. It reads whatever query params exist (as a hint, not a fact), falls back to localStorage if they're missing or the page loaded cold, and then asks your server to check with the gateway:
// /payment/callback page, runs on load
async function reconcilePayment() {
const params = new URLSearchParams(window.location.search);
const stored = JSON.parse(localStorage.getItem('pendingPayment') || 'null');
const paymentId = params.get('paymentID') || stored?.gatewayPaymentId;
const orderId = params.get('orderId') || stored?.orderId;
if (!paymentId) {
// Nothing in the URL and nothing in storage — genuinely lost context.
// Fall back to "check your order history" rather than a dead end.
return showOrderLookupPrompt();
}
await pollStatus(paymentId, orderId);
}
async function pollStatus(paymentId, orderId, attempt = 1) {
const maxAttempts = 8;
const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 15000); // capped exponential backoff
try {
const res = await fetch(`/api/payments/${paymentId}/status?orderId=${orderId}`);
if (!res.ok) throw new Error(`status ${res.status}`);
const { status } = await res.json();
if (status === 'paid') {
localStorage.removeItem('pendingPayment');
return showSuccess(orderId);
}
if (status === 'failed') {
localStorage.removeItem('pendingPayment');
return showFailure(orderId);
}
// status === 'pending' — gateway hasn't confirmed yet, keep polling
} catch (err) {
// Server still down / network blip — this is the exact failure we're guarding against
}
if (attempt < maxAttempts) {
setTimeout(() => pollStatus(paymentId, orderId, attempt + 1), backoffMs);
} else {
showRetryableError(paymentId, orderId); // "We're confirming your payment, check back in a moment" — never "payment failed"
}
}
The critical detail: a fetch failure (your server was down) and a pending status are treated the same way — keep polling, don't show an error. The only two terminal states that get rendered are paid and failed, both of which came from your server asking the gateway, never from the raw redirect URL. If your server was down for the first three attempts and comes back on the fourth, the user never even notices — the spinner just resolves.
3. The status endpoint queries the gateway, not your local "did the callback fire" flag
This is the piece that makes step 2 trustworthy. GET /api/payments/:id/status must not just check "did we receive a callback for this ID" — because if your server was down, the answer is always no, even for a successful payment. It has to actively ask the gateway:
@Get(':paymentId/status')
async getStatus(@Param('paymentId') paymentId: string) {
const order = await this.ordersService.findByGatewayPaymentId(paymentId);
// Already reconciled by a webhook or an earlier check — cheap path, no gateway call.
if (order?.paymentStatus === 'paid' || order?.paymentStatus === 'failed') {
return { status: order.paymentStatus };
}
// Not settled locally yet — ask the gateway directly. This works whether
// our callback endpoint was ever hit or not.
const gatewayStatus = await this.bkashService.queryPaymentStatus(paymentId);
if (gatewayStatus === 'Completed') {
await this.ordersService.markPaidIdempotently(order.id, paymentId); // same idempotent guard as the webhook path
return { status: 'paid' };
}
if (gatewayStatus === 'Failed' || gatewayStatus === 'Cancelled') {
await this.ordersService.markFailed(order.id, paymentId);
return { status: 'failed' };
}
return { status: 'pending' };
}
Note that this reuses the same idempotent "mark paid" logic a webhook handler would use — see the idempotency and transactions post for the unique-constraint pattern that makes it safe to call this from three different triggers (webhook, redirect callback, and this polling endpoint) without double-processing.
Why localStorage specifically, and what it doesn't solve
localStorage earns its place here for one reason: it survives everything the query string doesn't — page refreshes, the tab being backgrounded through a server outage, even the browser restoring the tab after a crash. It does not survive a different device (customer pays on their phone, checks on their laptop) or a cleared browser. That's fine, because it's not the source of truth — it's a hint that lets the frontend resume without asking the user "what was your payment ID again?" The actual truth always comes from step 3, asking the gateway.
For the cross-device case, or the user who genuinely lost all client-side context, the fallback is what payments teams have always relied on: a background reconciliation job that polls the gateway for any paymentID your system created but never got confirmed for, same as the bKash reconciliation job I've written about before. localStorage fixes the common case fast and silently; the reconciliation job is the backstop for everything it can't reach.
A note on webhooks — they don't fully solve this either
You might be thinking: "if I have webhooks configured, doesn't the gateway just retry until my server is back up?" Mostly, yes — that's exactly why webhooks should be your primary settlement path and the redirect callback should be treated as advisory, as I've argued before. But webhook retry windows aren't infinite (bKash and most gateways give up after a bounded number of retries over a limited window), and more importantly, the webhook doesn't help the user staring at the browser tab right now. Even if the webhook eventually lands and marks the order paid in your database five minutes later, the customer on the callback page needs to see something other than a dead 502 in the meantime. The localStorage + polling pattern is what makes the frontend resilient while the backend's own retry machinery (webhook redelivery, reconciliation jobs) does its slower, more durable work in parallel.
Putting it together
The full sequence, end to end:
- Before redirecting to the gateway, the frontend writes
{ orderId, gatewayPaymentId }tolocalStorage. - The gateway processes the payment on its own infrastructure — this part is unaffected by your server being down.
- The gateway redirects the browser back. If your server is down, the browser shows a network error or your callback route 502s.
- The user refreshes (or the tab was never dead to begin with). The callback page loads, reads
paymentIDfrom the URL if present, falls back tolocalStorageif not. - The frontend polls
GET /api/payments/:id/statuswith exponential backoff, treating "server unreachable" the same as "still pending" — never as a hard failure. - That endpoint asks the gateway directly for the authoritative status, rather than relying on whether a callback was ever received.
- Once the gateway confirms, the order is marked paid through the same idempotent path used by webhooks,
localStorageis cleared, and the user finally sees a result — possibly the same result they would have seen instantly if the server had never gone down at all.
None of this is exotic engineering. It's the same lesson every reliable payment integration eventually learns: never let a single request — especially one to your own server, at the one moment it's most likely to be mid-deploy — be the only place a "the user paid" fact can live. Put it in three places (the gateway's own records, your database via webhook, and a client-side breadcrumb), and let the slowest one recover without the user ever having to file a support ticket asking where their money went.