curl --request POST \
--url https://api.example.com/razorpayHandle payment events from Razorpay payment gateway
curl --request POST \
--url https://api.example.com/razorpayx-razorpay-signature: HMAC SHA-256 signature of the raw request bodyconst signature = req.headers['x-razorpay-signature']
const rawBody = req.body // Must be raw Buffer, not parsed JSON
const expectedSignature = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
throw new Error('Invalid signature')
}
express.raw() middleware to preserve the exact bytes sent by Razorpaycrypto.timingSafeEqual() to prevent timing attacksRAZORPAY_WEBHOOK_SECRET environment variable{
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "pay_abc123xyz",
"amount": 49900,
"currency": "INR",
"status": "captured",
"order_id": "order_xyz789",
"method": "card",
"notes": {
"purchaseId": "65f1a2b3c4d5e6f7g8h9i0j1"
},
"created_at": 1678901234
}
}
}
}
event: Event type identifierpayload.payment.entity.id: Razorpay payment IDpayload.payment.entity.notes.purchaseId: Internal SkillRise purchase IDpurchaseId is stored in Razorpay’s notes field when creating the order. This links Razorpay payments to internal purchase records.| Header | Type | Required | Description |
|---|---|---|---|
x-razorpay-signature | string | Yes | HMAC SHA-256 signature of raw body |
Content-Type | string | Yes | Must be application/json |
{
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "string",
"amount": 0,
"currency": "string",
"status": "captured",
"order_id": "string",
"method": "string",
"notes": {
"purchaseId": "string"
},
"created_at": 0
}
}
}
}
200 OK
{
"received": true
}
400 Bad Request
{
"error": "Invalid Razorpay webhook signature"
}
payment.captured eventspurchaseId from notes and paymentId from entitycompletePurchase(purchaseId, paymentId) servicecompletePurchase service handles duplicate webhook deliveries:
payment.captured events trigger purchase completionpurchaseId or paymentId is silently ignoredx-razorpay-signature header400 Bad Request with error message
Razorpay Behavior: Will retry webhook delivery with exponential backoff
purchaseId not present in payment notespaymentId missing from payment entity200 OK with {"received": true}
Behavior: Event is acknowledged to prevent retries, but no action is taken
completePurchase() throws an error:
crypto.timingSafeEqual() to prevent timing attacksRAZORPAY_WEBHOOK_SECRET// Apply raw body parser for Razorpay webhook route
app.post('/razorpay',
express.raw({ type: 'application/json' }),
razorpayWebhooks
)
// Use JSON parser for other routes
app.use(express.json())
express.json() middleware runs before this route, signature verification will fail. Always apply express.raw() specifically to this route.https://yourdomain.com/razorpaypayment.capturedRAZORPAY_WEBHOOK_SECRET=your_webhook_secret_here
┌─────────────┐
│ User │
│ Pays │
└──────┬──────┘
│
v
┌─────────────────┐
│ Razorpay │
│ Processes │
└────┬──────┬─────┘
│ │
│ └──────────────────┐
v v
┌─────────────┐ ┌──────────────────┐
│ Frontend │ │ Webhook (This) │
│ Verify │ │ Fallback │
└──────┬──────┘ └────────┬─────────┘
│ │
v v
┌──────────────────────────────┐
│ completePurchase() │
│ (Idempotent Service) │
└──────────────────────────────┘
server/controllers/webhooks.js:64
export const razorpayWebhooks = async (req, res) => {
const signature = req.headers['x-razorpay-signature']
const rawBody = req.body // Raw Buffer, not parsed JSON
// Verify HMAC signature
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'
})
}
// Parse after verification
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 })
}
server/services/payments/order.service.js - Handles purchase completion and enrollment