Skip to main content

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

ServiceEndpointEventsPurpose
ClerkPOST /clerkuser.created, user.updated, user.deletedSync user data to MongoDB
RazorpayPOST /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

1

Configure webhook in Clerk Dashboard

  1. Go to WebhooksAdd Endpoint
  2. URL: https://your-domain.com/clerk
  3. Subscribe to: user.created, user.updated, user.deleted
  4. Copy the Signing Secret
2

Add secret to environment

server/.env
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' 
    })
  }
}

Headers

Clerk sends these headers for verification:
svix-id: msg_2Lh9nRv8ypt2BrfXZkcwWsHXbM5
svix-timestamp: 1614265330
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

Razorpay Webhooks

Setup

1

Configure webhook in Razorpay Dashboard

  1. Go to SettingsWebhooksAdd New Webhook
  2. URL: https://your-domain.com/razorpay
  3. Events: payment.captured
  4. Click Create Webhook
  5. Copy the Webhook Secret
2

Add secret to environment

server/.env
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().
server/server.js
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

Headers

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:
  1. Signature matches expected HMAC
  2. Timestamp is recent (prevents replay attacks)
  3. 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:
ServiceRetry ScheduleMax Attempts
ClerkExponential backoff (1m, 5m, 30m, 2h, 5h, 10h)6
RazorpayLinear backoff (15m, 30m, 1h, 6h, 12h)5

Testing Webhooks Locally

Using ngrok

1

Install ngrok

npm install -g ngrok
2

Start your server

cd server
npm run server
3

Expose localhost

ngrok http 3000
Output:
Forwarding  https://abc123.ngrok.io -> http://localhost:3000
4

Update webhook URLs

  • Clerk: https://abc123.ngrok.io/clerk
  • Razorpay: https://abc123.ngrok.io/razorpay
5

Test webhooks

  • Clerk: Create a user in Clerk Dashboard
  • Razorpay: Make a test payment
  • Check server logs and database

Using Webhook Testing Tools

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 SettingsWebhooks → Click your webhook
  • View Recent Deliveries with response codes

Common Issues

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

1

Always verify signatures

Never process webhook events without cryptographic verification.
2

Use HTTPS in production

Webhook providers require HTTPS for security.
3

Validate event structure

Use Zod or similar to validate webhook payloads before processing.
4

Rate limiting

Apply rate limits to webhook endpoints to prevent abuse.
5

Secret rotation

Rotate webhook secrets periodically (every 90 days).
6

Monitor failures

Set up alerts for webhook failures (email, Slack, etc.).

Resources