Overview
SkillRise uses Stripe for processing course payments (Note: The codebase actually uses Razorpay as the payment provider, not Stripe. This documentation covers the Razorpay implementation).
The README mentions Stripe, but the actual implementation uses Razorpay . This is common in Indian e-learning platforms. The documentation below covers the actual Razorpay implementation.
Features
Secure checkout : Razorpay embedded payment UI
Multiple payment methods : Cards, UPI, Netbanking, Wallets
Webhook verification : HMAC-SHA256 signature validation
Order tracking : Purchase status management
Fallback enrollment : Webhook ensures enrollment even if frontend fails
Environment Variables
Server Configuration
Add these to your server/.env file:
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
RAZORPAY_WEBHOOK_SECRET=...
CURRENCY=INR
Get your keys from the Razorpay Dashboard . Create an account and generate API keys under Settings → API Keys .
Setup Instructions
Create Razorpay Account
Go to Razorpay
Sign up for an account
Complete KYC verification (required for live mode)
Start with Test Mode for development
Generate API Keys
Go to Settings → API Keys
Click Generate Test Keys (or Generate Live Keys for production)
Copy the Key ID and Key Secret
Add them to server/.env:
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
Configure Webhooks
Go to Settings → Webhooks
Click Add New Webhook
Enter your webhook URL:
Development: Use ngrok → https://your-ngrok-url.ngrok.io/razorpay
Production: https://your-domain.com/razorpay
Select events:
Click Create Webhook
Copy the Webhook Secret and add it to server/.env:
RAZORPAY_WEBHOOK_SECRET=...
Install Dependencies
cd server
npm install razorpay crypto
Payment Flow
The payment flow consists of three steps:
Create Order : Backend creates a Razorpay order
Process Payment : Frontend opens Razorpay checkout modal
Verify Payment : Backend verifies signature and completes enrollment
Webhook Fallback : Razorpay webhook ensures enrollment even if step 3 fails
1. Create Order
When a user initiates a purchase, the backend creates a Razorpay order:
server/services/payments/razorpay.service.js
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 ,
}
}
API Endpoint:
POST / api / user / purchase
Request :
{
"courseId" : "64abc123..."
}
Response :
{
"success" : true ,
"orderId" : "order_..." ,
"keyId" : "rzp_test_..." ,
"amount" : 2999 ,
"currency" : "INR"
}
2. Frontend Integration
Open Razorpay checkout modal on the frontend:
client/src/components/Checkout.jsx
const handlePayment = async () => {
// Create order
const response = await fetch ( '/api/user/purchase' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ courseId }),
})
const { orderId , keyId , amount , currency } = await response . json ()
// Open Razorpay checkout
const options = {
key: keyId ,
amount: amount ,
currency: currency ,
name: 'SkillRise' ,
description: 'Course Purchase' ,
order_id: orderId ,
handler : async ( response ) => {
// Verify payment on backend
await fetch ( '/api/user/verify-razorpay' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
razorpay_order_id: response . razorpay_order_id ,
razorpay_payment_id: response . razorpay_payment_id ,
razorpay_signature: response . razorpay_signature ,
}),
})
},
theme: { color: '#3b82f6' },
}
const razorpay = new window . Razorpay ( options )
razorpay . open ()
}
Load Razorpay script:
< script src = "https://checkout.razorpay.com/v1/checkout.js" ></ script >
3. Verify Payment Signature
The backend verifies the payment signature using HMAC-SHA256:
server/services/payments/razorpay.service.js
import crypto from 'crypto'
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 )
)
}
API Endpoint:
POST / api / user / verify - razorpay
Request :
{
"razorpay_order_id" : "order_..." ,
"razorpay_payment_id" : "pay_..." ,
"razorpay_signature" : "..."
}
Response :
{
"success" : true ,
"message" : "Payment verified and enrollment complete"
}
4. Webhook Implementation
Webhook acts as a reliable fallback if the frontend verification fails (network drop, browser close, etc.):
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' ]
const rawBody = req . body // raw Buffer (express.raw middleware)
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
const purchaseId = payment ?. notes ?. purchaseId
const paymentId = payment ?. id
if ( purchaseId && paymentId ) {
await completePurchase ( purchaseId , paymentId )
}
}
res . json ({ received: true })
}
Register webhook route:
import { razorpayWebhooks } from './controllers/webhooks.js'
// IMPORTANT: Use express.raw() for Razorpay webhooks
// Signature verification requires raw bytes, not parsed JSON
app . post ( '/razorpay' ,
express . raw ({ type: 'application/json' }),
razorpayWebhooks
)
// Then apply express.json() for other routes
app . use ( express . json ())
Razorpay webhook signature is computed over the exact raw bytes. The webhook route must use express.raw() middleware, not express.json(). Apply express.json() after the webhook route.
Rate Limiting
Protect payment endpoints from abuse:
import { rateLimit , ipKeyGenerator } from 'express-rate-limit'
const paymentLimiter = rateLimit ({
windowMs: 15 * 60 * 1000 , // 15 minutes
limit: 10 , // 10 requests per window
keyGenerator : ( req ) => req . auth ?. userId || ipKeyGenerator ( req ),
message: {
success: false ,
message: 'Too many payment attempts. Please try again later.'
},
})
app . use ( '/api/user/purchase' , paymentLimiter )
app . use ( '/api/user/verify-razorpay' , paymentLimiter )
Testing Payments
Test Cards
Razorpay provides test cards for development:
Card Number Type Result 4111 1111 1111 1111Visa Success 5555 5555 5555 4444Mastercard Success 4000 0000 0000 0002Visa Declined
Test card details:
CVV: Any 3 digits
Expiry: Any future date
Name: Any name
Test UPI
Use success@razorpay as the UPI ID in test mode.
Testing Webhooks Locally
Expose localhost
Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
Update Razorpay webhook URL
In Razorpay Dashboard → Settings → Webhooks: https://abc123.ngrok.io/razorpay
Test payment
Make a test purchase using a test card. Check:
Server logs for webhook event
MongoDB for completed purchase
User enrollment in course
Production Checklist
Switch to Live Mode
Complete KYC verification in Razorpay Dashboard
Generate Live API Keys
Update RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET with live keys
Update Webhook URL
Replace ngrok URL with your production domain: https://api.yourplatform.com/razorpay
Enable HTTPS
Razorpay requires HTTPS for webhooks in production. Use:
Let’s Encrypt (free SSL)
Cloudflare (free SSL + CDN)
Your hosting provider’s SSL
Test End-to-End
Make a real small payment (₹1)
Verify enrollment completes
Check webhook logs
Test refund flow
Common Issues
Webhook signature verification fails
Ensure you’re using express.raw() middleware for the webhook route
Verify RAZORPAY_WEBHOOK_SECRET matches the secret in Razorpay Dashboard
Check that the webhook route is registered before express.json()
Confirm headers x-razorpay-signature is being sent
Payment succeeds but enrollment fails
Check server logs for errors in completePurchase() function
Verify MongoDB connection is active
Ensure the purchaseId is correctly stored in Razorpay order notes
Check that the webhook is subscribed to payment.captured event
Razorpay amounts are in paise (smallest currency unit)
Convert rupees to paise: amount * 100
Example: ₹2999 → 299900 paise
Both frontend verification and webhook can trigger enrollment
Ensure completePurchase() is idempotent (checks if already completed)
Add unique constraints on Purchase model
Security Best Practices
Verify Signatures Always verify webhook signatures using crypto.timingSafeEqual() to prevent timing attacks.
Use HTTPS Never send API keys or handle payments over HTTP. Always use HTTPS in production.
Rate Limiting Apply strict rate limits to payment endpoints to prevent abuse and fraud attempts.
Secure Keys Never commit API keys to git. Use environment variables and keep secrets secure.
Resources