DEV Community

Dibyajyoti
Dibyajyoti

Posted on

How to Integrate Razorpay in a MERN App with Backend Verification

Building a Learning Management System (LMS) like Ascend requires more than just displaying videos; it requires a seamless, secure way for users to purchase content.

In this article, I’ll walk you through how I integrated Razorpay into my MERN stack project, ensuring that every transaction is verified on the backend before granting course access.


The Workflow

Before diving into code, it’s important to understand the handshake between your app and Razorpay. We don't just trust the frontend; we verify every payment signature on our server to prevent fraud.


1. Frontend Setup: Preparing the UI

First, we need the Razorpay checkout script in our index.html. This injects the global Razorpay object into our window.

<script src="https://checkout.razorpay.com/v1/checkout.js"></script>

Enter fullscreen mode Exit fullscreen mode

TypeScript Definitions

Since I built Ascend with TypeScript, I needed to define the Razorpay types to avoid the dreaded "property does not exist on window" error. I created a src/types/razorpay.d.ts file:

export {};

declare global {
  interface Window {
    Razorpay: new (options: RazorpayOptions) => RazorpayInstance;
  }

  interface RazorpayOptions {
    key: string;
    amount: number;
    currency: string;
    name?: string;
    description?: string;
    order_id?: string;
    handler?: (response: RazorpayResponse) => void;
    prefill?: {
      name?: string;
      email?: string;
      contact?: string;
    };
    theme?: {
      color?: string;
    };
  }

  interface RazorpayInstance {
    open(): void;
    on(event: string, callback: () => void): void;
  }

  interface RazorpayResponse {
    razorpay_payment_id: string;
    razorpay_order_id: string;
    razorpay_signature: string;
  }
}

Enter fullscreen mode Exit fullscreen mode

2. Backend Phase 1: Creating the Order

When a student clicks "Enroll," we don't just open the modal. We first ask our server to create a Razorpay Order. This ensures the price is fetched from our database, not sent from the client (where it could be tampered with).

The Route:

userRouter.post("/purchase-rzp", protect, wrapAsync(purchaseCourseRZP));

Enter fullscreen mode Exit fullscreen mode

The Controller:
In purchaseCourseRZP, we calculate the final price, create a "pending" purchase record in our DB, and then initialize the Razorpay order.

export const purchaseCourseRZP = async (req, res) => {
  const { courseId } = req.body;
  const { userId } = await req.auth();

  const userData = await User.findById(userId);
  const courseData = await Course.findById(courseId);

  if (!userData || !courseData) {
    throw new ExpressError(404, "Data not found");
  }

  const finalAmount =
    courseData.coursePrice -
    (courseData.discount * courseData.coursePrice) / 100;

  // Create a record in our database first
  const newPurchase = new Purchase({
    courseId: courseData._id,
    userId,
    amount: finalAmount,
    status: "pending",
  });

  await newPurchase.save();

  // initialize razorpay
  const razorpayInstance = new Razorpay({
    key_id: process.env.RZP_KEY_ID,
    key_secret: process.env.RZP_KEY_SECRET,
  });

  const options = {
    amount: Math.round(finalAmount * 100), // paisa
    currency: "INR",
    receipt: `receipt_${newPurchase._id}`,
    notes: {
      purchaseId: newPurchase._id.toString(),
    },
  };

  const order = await razorpayInstance.orders.create(options);

  res.json({
    success: true,
    orderId: order.id,
    amount: order.amount,
    key: process.env.RZP_KEY_ID,
    purchaseId: newPurchase._id,
  });
};

Enter fullscreen mode Exit fullscreen mode

3. Frontend Phase 2: Opening the Modal

Once the backend returns the orderId, we trigger the Razorpay popup in CourseDetails.tsx.

  const enrollCourse = async () => {
    try {
      if (!userData) return toast.error("Login to enroll");
      if (isAlreadyEnrolled) return toast.error("Already enrolled");

      const token = await getToken();
      if (!token) return toast.error("Unauthorized");

      const { data } = await axios.post(
        backendUrl + "/api/user/purchase-rzp",
        { courseId: courseData?._id },
        { headers: { Authorization: `Bearer ${token}` } },
      );

      if (!data.success) return toast.error("Something went wrong");

      const options = {
        key: data.key,
        amount: data.amount,
        currency: "INR",
        order_id: data.orderId,

        // This runs after a successful payment
        handler: async (response: RazorpayResponse) => {
          await axios.post(
            backendUrl + "/api/user/verify-rzp",
            {
              ...response,
              purchaseId: data.purchaseId,
            },
            { headers: { Authorization: `Bearer ${token}` } },
          );

          window.location.replace("/loading/my-enrollments");
        },
      };

      const rzp = new window.Razorpay(options);
      rzp.open();
    } catch (error) {
      const msg =
        error instanceof Error ? error.message : "Something went wrong";
      toast.error(msg);
    }
  };

Enter fullscreen mode Exit fullscreen mode

4. Backend Phase 3: The Critical Security Check

This is where the magic happens. When the user pays, Razorpay gives them a razorpay_signature. We must verify this signature on our server using our RZP_KEY_SECRET to confirm the payment is legitimate.

The Verification Logic:
We use HMAC SHA256 to create a signature on our end and compare it with the one provided by the frontend.

export const verifyRazorpayPayment = async (req, res) => {
  const {
    razorpay_order_id,
    razorpay_payment_id,
    razorpay_signature,
    purchaseId,
  } = req.body;

  const body = razorpay_order_id + "|" + razorpay_payment_id;

  const expectedSignature = crypto
    .createHmac("sha256", process.env.RZP_KEY_SECRET)
    .update(body)
    .digest("hex");

  if (expectedSignature !== razorpay_signature) {
    await Purchase.findByIdAndUpdate(purchaseId, {
      status: "failed",
    });
    throw new ExpressError(400, "Invalid signature");
  }

  const purchaseData = await Purchase.findById(purchaseId);
  const userData = await User.findById(purchaseData.userId);
  const courseData = await Course.findById(purchaseData.courseId);

  // enroll student
  courseData.enrolledStudents.push(userData._id);
  await courseData.save();

  userData.enrolledCourses.push(courseData._id);
  await userData.save();

  purchaseData.status = "completed";
  purchaseData.paymentId = razorpay_payment_id;
  await purchaseData.save();

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

Enter fullscreen mode Exit fullscreen mode

Summary

By following this flow, Ascend ensures that:

  1. Price Integrity: Prices are calculated server-side.
  2. Atomicity: A purchase record is created before the payment starts.
  3. Security: No student is enrolled unless the cryptographic signature from Razorpay is verified.

Top comments (0)