Overview
SkillRise integrates Razorpay for payment processing with a robust webhook-based enrollment system. The architecture ensures reliable course enrollment even when frontend verification fails.
Payment Architecture
The webhook serves as a reliable fallback mechanism, ensuring enrollment completes even if the frontend verification fails due to network issues or browser closure.
Purchase Model
Purchase Schema
server/models/Purchase.js
import mongoose from 'mongoose'
const purchaseSchema = new mongoose . Schema (
{
courseId: {
type: mongoose . Schema . Types . ObjectId ,
ref: 'Course' ,
required: true
},
userId: {
type: String ,
ref: 'User' ,
required: true ,
},
amount: { type: Number , required: true },
currency: { type: String , default: 'INR' },
providerOrderId: { type: String },
providerPaymentId: { type: String },
status: {
type: String ,
enum: [ 'created' , 'pending' , 'completed' , 'failed' , 'refunded' ],
default: 'created' ,
},
},
{ timestamps: true }
)
const Purchase = mongoose . model ( 'Purchase' , purchaseSchema )
export default Purchase
Purchase Status Flow
created
Initial state when purchase record is created before Razorpay order.
pending
Razorpay order created, waiting for payment completion.
completed
Payment verified, user enrolled in course.
failed
Payment failed or was cancelled by user.
refunded
Payment was refunded to the user.
Razorpay Service
Create Order
Generate a Razorpay order with purchase metadata:
server/services/payments/razorpay.service.js
import crypto from 'crypto'
import Razorpay from 'razorpay'
import Purchase from '../../models/Purchase.js'
export const createOrder = async ({ purchaseId , amount , courseTitle }) => {
const razorpayInstance = new Razorpay ({
key_id: process . env . RAZORPAY_KEY_ID ,
key_secret: process . env . RAZORPAY_KEY_SECRET ,
})
const currency = process . env . CURRENCY || 'INR'
const order = await razorpayInstance . orders . create ({
amount: Math . round ( amount * 100 ), // convert to paise
currency ,
receipt: purchaseId . toString (),
notes: {
purchaseId: purchaseId . toString (),
courseTitle ,
},
})
// Store the Razorpay order id on the Purchase
await Purchase . findByIdAndUpdate ( purchaseId , {
providerOrderId: order . id
})
return {
orderId: order . id ,
keyId: process . env . RAZORPAY_KEY_ID ,
}
}
The purchaseId is stored in Razorpay’s notes field, allowing the webhook to identify which internal purchase to complete.
Verify Payment Signature
Verify payment authenticity using HMAC-SHA256:
server/services/payments/razorpay.service.js
export const verifyPayment = ({ orderId , paymentId , signature }) => {
const expected = crypto
. createHmac ( 'sha256' , process . env . RAZORPAY_KEY_SECRET )
. update ( ` ${ orderId } | ${ paymentId } ` )
. digest ( 'hex' )
return crypto . timingSafeEqual (
Buffer . from ( expected ),
Buffer . from ( signature )
)
}
Always use crypto.timingSafeEqual() for signature comparison to prevent timing attacks.
Purchase Completion Service
Idempotent Enrollment
The core enrollment logic is idempotent and centralized:
server/services/payments/order.service.js
import Purchase from '../../models/Purchase.js'
import User from '../../models/User.js'
import Course from '../../models/Course.js'
export const completePurchase = async ( purchaseId , providerPaymentId ) => {
const purchase = await Purchase . findById ( purchaseId )
if ( ! purchase ) {
console . warn ( `completePurchase: purchase ${ purchaseId } not found` )
return
}
// Idempotency guard — webhook may fire more than once
if ( purchase . status === 'completed' ) return
// Enroll user in course (addToSet prevents duplicates)
await Course . findByIdAndUpdate ( purchase . courseId , {
$addToSet: { enrolledStudents: purchase . userId },
$inc: { totalEnrolledStudents: 1 },
})
await User . findByIdAndUpdate ( purchase . userId , {
$addToSet: { enrolledCourses: purchase . courseId },
})
purchase . status = 'completed'
purchase . providerPaymentId = providerPaymentId
await purchase . save ()
}
This function is the single source of truth for enrollment. It’s called from both the frontend verification endpoint and the webhook handler.
The $addToSet operator ensures that calling this function multiple times won’t create duplicate enrollments.
Webhook Handler
Razorpay Webhook Verification
Securely handle Razorpay payment capture webhooks:
server/controllers/webhooks.js
import crypto from 'crypto'
import { completePurchase } from '../services/payments/order.service.js'
export const razorpayWebhooks = async ( req , res ) => {
const signature = req . headers [ 'x-razorpay-signature' ]
// req.body is a raw Buffer here (express.raw middleware)
const rawBody = req . body
const expectedSignature = crypto
. createHmac ( 'sha256' , process . env . RAZORPAY_WEBHOOK_SECRET )
. update ( rawBody )
. digest ( 'hex' )
if (
! signature ||
! crypto . timingSafeEqual (
Buffer . from ( expectedSignature ),
Buffer . from ( signature )
)
) {
return res . status ( 400 ). json ({
error: 'Invalid Razorpay webhook signature'
})
}
const event = JSON . parse ( rawBody . toString ())
if ( event . event === 'payment.captured' ) {
const payment = event . payload ?. payment ?. entity
// purchaseId was stored in Razorpay's `notes` field
const purchaseId = payment ?. notes ?. purchaseId
const paymentId = payment ?. id
if ( purchaseId && paymentId ) {
await completePurchase ( purchaseId , paymentId )
}
}
res . json ({ received: true })
}
Signature Verification
Verify the webhook signature using the raw body before parsing JSON.
Event Filtering
Only process payment.captured events to avoid handling incomplete payments.
Purchase ID Extraction
Extract the internal purchase ID from Razorpay’s notes field.
Complete Enrollment
Call the idempotent completePurchase function to enroll the user.
Critical : Verify the webhook signature on the raw body before parsing JSON. Parsing before verification will cause signature mismatch.
Express Middleware Configuration
The webhook endpoint requires special body parsing:
import express from 'express'
const app = express ()
// Raw body parser for Razorpay webhooks
app . use (
'/api/webhooks/razorpay' ,
express . raw ({ type: 'application/json' })
)
// JSON parser for all other routes
app . use ( express . json ())
The raw body parser must be applied before the JSON parser to preserve the original request body for signature verification.
Environment Variables
RAZORPAY_KEY_ID = rzp_test_xxxxxxxxxxxxx
RAZORPAY_KEY_SECRET = xxxxxxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET = xxxxxxxxxxxxxxxxxxxxx
CURRENCY = INR
Payment Flow States
Purchase record created with status: 'created'. Razorpay order generated and checkout modal opened.
User completes payment on Razorpay. Status updated to pending while awaiting capture.
Frontend receives payment success and calls verify endpoint. If successful, enrollment completes immediately.
Razorpay sends payment.captured webhook. If frontend verification failed, webhook ensures enrollment completes.
User added to course’s enrolledStudents array and course added to user’s enrolledCourses array.
Security Features
HMAC Signature Verification All webhook requests are verified using HMAC-SHA256 signatures.
Timing-Safe Comparison Signature comparison uses timing-safe functions to prevent timing attacks.
Idempotent Operations Purchase completion can be called multiple times safely without duplicate enrollments.
Raw Body Verification Webhook signatures are verified on raw body to prevent tampering.
Error Handling
Payment Failed
Webhook Replay
Invalid Signature
Missing Purchase ID
If payment fails, the purchase status remains created or changes to failed. User is not enrolled.
Idempotency check prevents duplicate enrollments if webhook fires multiple times.
Returns 400 error and ignores the webhook payload to prevent unauthorized access.
Webhook completes successfully but enrollment is skipped if purchase ID is missing.
Best Practices
Dual Verification Path : Implement both frontend verification and webhook handling to ensure enrollment reliability.
Store Metadata in Notes : Use Razorpay’s notes field to store internal IDs that survive through their system.
Centralize Enrollment Logic : Keep enrollment logic in a single idempotent function called by both verification paths.
Next Steps