Overview
SkillRise uses webhooks from Clerk and Razorpay to sync data and process payments. Webhooks allow external services to notify your server when events occur.
Active Webhooks
Service Endpoint Events Purpose Clerk POST /clerkuser.created, user.updated, user.deletedSync user data to MongoDB Razorpay POST /razorpaypayment.capturedComplete enrollment after payment
Webhook Security
All webhooks use cryptographic signatures to verify authenticity:
Clerk : Uses Svix for signature verification
Razorpay : Uses HMAC-SHA256 signatures
Never process webhook events without verifying signatures. Attackers can forge requests to your webhook endpoints.
Clerk Webhooks
Setup
Configure webhook in Clerk Dashboard
Go to Webhooks → Add Endpoint
URL: https://your-domain.com/clerk
Subscribe to: user.created, user.updated, user.deleted
Copy the Signing Secret
Add secret to environment
CLERK_WEBHOOK_SECRET=whsec_...
Implementation
server/controllers/webhooks.js
import { Webhook } from 'svix'
import User from '../models/User.js'
export const clerkWebhooks = async ( req , res ) => {
try {
const webhook = new Webhook ( process . env . CLERK_WEBHOOK_SECRET )
// Verify signature
await webhook . verify ( JSON . stringify ( req . body ), {
'svix-id' : req . headers [ 'svix-id' ],
'svix-timestamp' : req . headers [ 'svix-timestamp' ],
'svix-signature' : req . headers [ 'svix-signature' ],
})
const { data , type } = req . body
switch ( type ) {
case 'user.created' : {
const userData = {
_id: data . id ,
email: data . email_addresses [ 0 ]. email_address ,
name: data . first_name + ' ' + data . last_name ,
imageUrl: data . image_url ,
}
await User . create ( userData )
res . json ({})
break
}
case 'user.updated' : {
const userData = {
email: data . email_addresses [ 0 ]. email_address ,
name: data . first_name + ' ' + data . last_name ,
imageUrl: data . image_url ,
}
await User . findByIdAndUpdate ( data . id , userData )
res . json ({})
break
}
case 'user.deleted' : {
await User . findByIdAndDelete ( data . id )
res . json ({})
break
}
default :
res . json ({})
}
} catch ( error ) {
console . error ( error )
res . status ( 500 ). json ({
success: false ,
message: 'An unexpected error occurred'
})
}
}
Clerk sends these headers for verification:
svix-id : msg_2Lh9nRv8ypt2BrfXZkcwWsHXbM5
svix-timestamp : 1614265330
svix-signature : v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Razorpay Webhooks
Setup
Configure webhook in Razorpay Dashboard
Go to Settings → Webhooks → Add New Webhook
URL: https://your-domain.com/razorpay
Events: payment.captured
Click Create Webhook
Copy the Webhook Secret
Add secret to environment
RAZORPAY_WEBHOOK_SECRET=...
Implementation
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 (not parsed JSON)
// Verify signature using HMAC-SHA256
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 event 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 })
}
Critical: Raw Body Requirement
Razorpay’s HMAC signature is computed over the exact raw bytes sent. You must use express.raw() middleware for the webhook route, not express.json().
import express from 'express'
import { razorpayWebhooks } from './controllers/webhooks.js'
// CORRECT: Use express.raw() for Razorpay webhook
app . post ( '/razorpay' ,
express . raw ({ type: 'application/json' }),
razorpayWebhooks
)
// Then apply express.json() for other routes
app . use ( express . json ())
Why this matters:
express.json() parses and re-stringifies the body, changing bytes
Signature verification will fail if bytes are modified
express.raw() preserves the exact bytes as a Buffer
Razorpay sends this header:
x-razorpay-signature : 2e3c4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e
Signature Verification Deep Dive
Clerk (Svix)
Clerk uses the Svix library for webhook signatures:
import { Webhook } from 'svix'
const webhook = new Webhook ( process . env . CLERK_WEBHOOK_SECRET )
// Verify throws an error if signature is invalid
await webhook . verify ( JSON . stringify ( req . body ), {
'svix-id' : req . headers [ 'svix-id' ],
'svix-timestamp' : req . headers [ 'svix-timestamp' ],
'svix-signature' : req . headers [ 'svix-signature' ],
})
What Svix checks:
Signature matches expected HMAC
Timestamp is recent (prevents replay attacks)
Message ID hasn’t been seen before (prevents duplicate processing)
Razorpay (HMAC-SHA256)
Razorpay uses standard HMAC-SHA256:
import crypto from 'crypto'
const expectedSignature = crypto
. createHmac ( 'sha256' , process . env . RAZORPAY_WEBHOOK_SECRET )
. update ( rawBody ) // Buffer, not string
. digest ( 'hex' )
const isValid = crypto . timingSafeEqual (
Buffer . from ( expectedSignature ),
Buffer . from ( signature )
)
Why timingSafeEqual?
Prevents timing attacks
Regular === comparison leaks information through timing
timingSafeEqual always takes the same time, regardless of where strings differ
Webhook Resilience
Idempotency
Webhooks may be delivered multiple times. Make handlers idempotent:
// BAD: Always creates a new user
await User . create ({ _id: data . id , ... })
// GOOD: Creates only if doesn't exist
await User . findOneAndUpdate (
{ _id: data . id },
{ email: data . email , ... },
{ upsert: true }
)
Error Handling
export const clerkWebhooks = async ( req , res ) => {
try {
// Verify signature
await webhook . verify ( ... )
// Process event
await processEvent ( req . body )
// Always return 200 to acknowledge receipt
res . json ({})
} catch ( error ) {
// Log error for debugging
console . error ( 'Webhook error:' , error )
// Return 500 to trigger retry
res . status ( 500 ). json ({
success: false ,
message: 'An unexpected error occurred'
})
}
}
Best practices:
Return 200 OK to acknowledge successful processing
Return 400 for invalid signatures (don’t retry)
Return 500 for transient errors (service will retry)
Log all errors for debugging
Retry Logic
Both Clerk and Razorpay retry failed webhooks:
Service Retry Schedule Max Attempts Clerk Exponential backoff (1m, 5m, 30m, 2h, 5h, 10h) 6 Razorpay Linear backoff (15m, 30m, 1h, 6h, 12h) 5
Testing Webhooks Locally
Using ngrok
Expose localhost
Output: Forwarding https://abc123.ngrok.io -> http://localhost:3000
Update webhook URLs
Clerk: https://abc123.ngrok.io/clerk
Razorpay: https://abc123.ngrok.io/razorpay
Test webhooks
Clerk: Create a user in Clerk Dashboard
Razorpay: Make a test payment
Check server logs and database
Clerk:
Go to Webhooks → Your endpoint → Testing
Click Send Example to send test events
Razorpay:
Use Webhook.site to inspect payloads
Copy payload and replay via curl:
curl -X POST https://abc123.ngrok.io/razorpay \
-H "Content-Type: application/json" \
-H "x-razorpay-signature: ..." \
-d @payload.json
Monitoring Webhooks
Log All Events
export const clerkWebhooks = async ( req , res ) => {
const { type } = req . body
console . log ( `[Webhook] Clerk event: ${ type } ` , {
timestamp: new Date (). toISOString (),
headers: req . headers ,
})
// Process event...
}
Track Failures
import WebhookLog from '../models/WebhookLog.js'
export const razorpayWebhooks = async ( req , res ) => {
try {
// Verify and process...
await WebhookLog . create ({
service: 'razorpay' ,
event: req . body . event ,
status: 'success' ,
timestamp: new Date (),
})
res . json ({ received: true })
} catch ( error ) {
await WebhookLog . create ({
service: 'razorpay' ,
event: req . body . event ,
status: 'failed' ,
error: error . message ,
timestamp: new Date (),
})
res . status ( 500 ). json ({ error: 'Failed to process webhook' })
}
}
Dashboard Monitoring
Clerk:
Go to Webhooks → Your endpoint → Logs
View recent deliveries, status codes, and retry attempts
Razorpay:
Go to Settings → Webhooks → Click your webhook
View Recent Deliveries with response codes
Common Issues
Signature verification fails
Clerk:
Verify CLERK_WEBHOOK_SECRET matches Clerk Dashboard
Check headers: svix-id, svix-timestamp, svix-signature
Ensure you’re stringifying the body: JSON.stringify(req.body)
Razorpay:
Verify RAZORPAY_WEBHOOK_SECRET matches Razorpay Dashboard
Critical : Use express.raw(), not express.json()
Register webhook route before express.json() middleware
Check firewall/security groups allow incoming HTTPS
Verify URL is correct (no typos, correct protocol)
Test with ngrok to rule out network issues
Check webhook is subscribed to correct events
Make handlers idempotent (use upsert instead of create)
Track processed webhook IDs to skip duplicates
Use database unique constraints
Webhooks may arrive out of order
Use timestamps to order events
Design handlers to be order-independent
Security Checklist
Always verify signatures
Never process webhook events without cryptographic verification.
Use HTTPS in production
Webhook providers require HTTPS for security.
Validate event structure
Use Zod or similar to validate webhook payloads before processing.
Rate limiting
Apply rate limits to webhook endpoints to prevent abuse.
Secret rotation
Rotate webhook secrets periodically (every 90 days).
Monitor failures
Set up alerts for webhook failures (email, Slack, etc.).
Resources