DEV Community

Cover image for Stop Trusting Your Frontend for Payment Confirmation — Use Webhooks on Stripe and Razorpay
Samaresh Das
Samaresh Das

Posted on

Stop Trusting Your Frontend for Payment Confirmation — Use Webhooks on Stripe and Razorpay

Your user just paid. Your frontend fired a success callback. You updated the database, unlocked the feature, sent a thank-you email.

And then their tab closed mid-redirect. Or the network dropped for 400ms. Or they hit refresh at exactly the wrong moment.

Your backend never got the confirmation. But Razorpay and Stripe both show the payment as successful.

Congratulations — you just gave away your product for free.

This is the most common and most expensive mistake developers make when integrating payments. And the fix is a single architectural decision: never trust the frontend for payment confirmation. Let the webhook do it.


The Problem With Frontend-Only Payment Handling

Both Stripe and Razorpay give you a hosted payment page — Stripe Checkout and Razorpay's standard checkout — that handles the entire payment UI. Cards, UPI, wallets, 3D Secure, everything. You don't need to build any of that.

After a successful payment, both platforms redirect the user back to your app with a session ID or payment ID in the URL. Many developers then do this:

  1. Read the payment ID from the URL on the frontend
  2. Send it to their backend
  3. Backend confirms the order
  4. Done

This looks fine. It works in testing. It fails in production in ways that are nearly impossible to reproduce.

Here's why:

  • The redirect can fail. User closes the tab, network drops, browser crashes — the redirect never fires.
  • The frontend can be tampered with. Anyone with basic dev tools knowledge can fake a success callback or manipulate URL parameters.
  • The frontend is not your system. It runs on the user's device, in the user's browser, on the user's network. None of that is in your control.

The result: payments that succeed on the gateway but never register in your database. Or worse — someone manipulates the response and gets access without paying at all.


What Webhooks Actually Do

A webhook is an automated POST request that Stripe or Razorpay sends directly to your backend server the moment a payment event occurs. It bypasses the frontend entirely.

The flow looks like this:

  1. User completes payment on the hosted checkout page
  2. Stripe/Razorpay processes the payment
  3. Stripe/Razorpay sends a POST request to your webhook endpoint (e.g. /api/webhooks/stripe)
  4. Your server verifies the signature, updates the database, fulfills the order
  5. Your server returns HTTP 200
  6. User gets redirected to your success page

Step 4 happens on your infrastructure, initiated by the payment gateway, completely independent of what the user's browser does. The user can close their tab, lose internet, or throw their laptop out the window — your backend still gets the event.


Webhook Events You Actually Need

Stripe

  • checkout.session.completed — One-time payment succeeded via hosted checkout. This is your primary fulfillment trigger.
  • payment_intent.succeeded — Payment intent confirmed. Use this if you're building a custom flow rather than Stripe Checkout.
  • invoice.payment_succeeded — Subscription renewal paid successfully.
  • invoice.payment_failed — Subscription renewal failed. Trigger dunning flows here.
  • customer.subscription.updated — Plan upgraded, downgraded, or cancelled.
  • customer.subscription.deleted — Subscription ended. Revoke access here, not on the frontend.

Razorpay

  • payment.captured — Payment successfully captured. Your main fulfillment event.
  • payment.failed — Payment failed after initiation. Log it, notify the user, don't fulfill.
  • subscription.charged — Subscription renewed. Update access.
  • subscription.cancelled — Subscription cancelled. Revoke access.
  • refund.created — Refund issued. Update order status accordingly.

Signature Verification — Non-Negotiable

Anyone on the internet can POST to your webhook endpoint. Without signature verification, a bad actor can fake a payment event and unlock your product for free.

Both Stripe and Razorpay sign every webhook payload with a secret key. Your job is to verify that signature before processing anything.

Stripe — Node.js


const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    // fulfill order here
  }

  res.json({ received: true });
});

Critical: Use express.raw() not express.json() for the Stripe webhook route. Stripe's signature is computed on the raw request body. If you parse it as JSON first, the signature check will always fail.

Razorpay — Node.js


const Razorpay = require('razorpay');
const crypto = require('crypto');

app.post('/api/webhooks/razorpay', express.json(), (req, res) => {
  const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
  const signature = req.headers['x-razorpay-signature'];

  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (expectedSignature !== signature) {
    return res.status(400).json({ error: 'Invalid signature' });
  }

  const event = req.body.event;

  if (event === 'payment.captured') {
    const payment = req.body.payload.payment.entity;
    // fulfill order here
  }

  res.json({ received: true });
});

Idempotency — Handle Duplicate Events

Both gateways deliver webhooks on an "at least once" basis. Network timeouts, retries, and edge cases mean your endpoint can receive the same event multiple times. If you're not handling this, you can fulfill the same order twice or send duplicate emails.

The fix is simple — store the event ID and check before processing:


// Before fulfilling any order
const existingEvent = await db.webhookEvents.findOne({ eventId: event.id });
if (existingEvent) {
  return res.json({ received: true }); // already processed, skip
}

await db.webhookEvents.create({ eventId: event.id, processedAt: new Date() });
// now fulfill the order safely

This pattern is called idempotency. One event, one fulfillment. No exceptions.


What To Do on the Frontend — Nothing Critical

This is the mindset shift: your frontend success page is purely cosmetic.

When Stripe or Razorpay redirects the user to your success URL, show them a nice confirmation page. That's it. Don't unlock features, don't send emails, don't update databases from the frontend. All of that has already happened — or is happening — on your backend via the webhook.

If you want to show order details on the success page, poll your own backend for the order status using the session ID from the URL. When the webhook completes, your backend has the data. The frontend just reads it.


// On your success page — poll until the order is confirmed
const checkOrder = async (sessionId) => {
  const res = await fetch(`/api/orders/status?session=${sessionId}`);
  const data = await res.json();

  if (data.status === 'fulfilled') {
    showSuccessUI(data.order);
  } else {
    setTimeout(() => checkOrder(sessionId), 2000); // retry in 2s
  }
};

Common Mistakes to Avoid

  • Using express.json() on your Stripe webhook route. Breaks signature verification. Always use express.raw().
  • Not returning HTTP 200 fast enough. Both gateways expect a 200 response quickly. Do your heavy processing asynchronously — queue it if needed.
  • Only listening to success events. Handle failures, refunds, and subscription changes too. Your database should always reflect what the gateway knows.
  • Skipping idempotency. At-least-once delivery is a guarantee, not a bug. Build for it.
  • Storing webhook secrets in code. Always use environment variables. Rotate them if exposed.

The Architecture in One Line

Hosted checkout page handles the payment UI. Webhook handles your business logic. Frontend shows the result. Nothing critical ever depends on the user's browser completing a redirect.

That's it. Ship it like that and you'll never lose a payment confirmation again.


Follow for more practical backend and payment integration guides for developers building real products in 2026.

Top comments (0)