Mahbubul Alam.
Backend

Designing Webhook-Based Payment Confirmation That Doesn't Lose Money

Jul 20262 min read
Designing Webhook-Based Payment Confirmation That Doesn't Lose Money

Why the redirect isn't confirmation

Every payment gateway integration tutorial shows the happy path: user pays, gateway redirects back to your site with a success parameter, you mark the order paid. The problem is that redirect is a client-side browser navigation — the user can close the tab before it happens, their connection can drop, or (less innocently) they can manually hit your success URL with a fabricated query string. None of that is proof that money actually moved. The webhook the gateway sends server-to-server, independent of what the user's browser does, is the only signal you can trust.

The architecture: webhook as source of truth, redirect as UX only

// The redirect handler shows the user a "processing" state — it does NOT mark anything paid
app.get("/payment/callback", (req, res) => {
  res.render("processing", { orderId: req.query.order_id });
});

// The webhook is what actually confirms and unlocks the order
app.post("/webhooks/payment", verifyGatewaySignature, async (req, res) => {
  const { orderId, status, transactionId } = req.body;
  await confirmPayment(orderId, status, transactionId);
  res.sendStatus(200); // acknowledge receipt so the gateway stops retrying
});

The frontend polls or subscribes (Socket.io works well here) for the order's actual status, which only changes once the webhook has been processed — so the UI reflects reality, not an assumption based on the browser successfully navigating back.

Three things that will bite you if you skip them

  • Signature verification. If you don't verify the webhook's signature against the gateway's secret, anyone who knows your webhook URL can POST a fake "payment confirmed" event. Every gateway provides a way to verify this — using it isn't optional.
  • Idempotent handling. Gateways retry webhooks on timeout or non-200 response. If your handler isn't idempotent, a retried webhook for an already-confirmed payment can double-fulfil an order. Check the current state before acting, not just the incoming payload.
  • Out-of-order delivery. A "payment failed" webhook can arrive after a "payment succeeded" one under real network conditions. Trust the transaction's actual current status from the gateway's API when in doubt, not just the sequence you received events in.

Reconciliation as a safety net, not a first line of defense

Even a well-built webhook system should have a backstop: a scheduled job that queries the gateway's API directly for any order stuck in "processing" beyond a reasonable window (say, 10 minutes) and reconciles it manually. Webhook delivery isn't guaranteed by any provider — network partitions, gateway-side outages, and misconfigured endpoints all happen. The reconciliation job is what turns "we lost a webhook" from a silent revenue leak into a self-healing edge case.

The rule that ties it together

Never let a client-controlled value (a redirect query param, a form field) be the thing that marks money as received. The only things that should ever confirm a payment are a verified webhook or a direct, authenticated query to the gateway's own API. Everything else is UX, not truth.

#webhooks#payments#reliability