Razorpay Webhook Security: Signature Verification Guide
If your Razorpay webhook endpoint doesn't verify the signature, anyone who knows your URL can send a fake payment.captured event and trigger your fulfillment logic for free. Here's how to fix it in 10 minutes.
Why this matters
Razorpay sends a X-Razorpay-Signature header with every webhook. This header is an HMAC-SHA256 signature of the request body, signed with your webhook secret. If you don't verify it, your endpoint trusts any POST request — including ones crafted by an attacker.
What an attacker can do without signature verification:
- Send a fake
payment.capturedevent → trigger account upgrade without paying - Send a fake
subscription.activated→ get pro access for free - Replay a real webhook multiple times → trigger duplicate fulfillment
Vezraa's scanner catches this — it checks if your /api/razorpay*, /api/webhook*, and /api/payment* routes perform signature verification before processing events.
How to verify the signature
Next.js App Router (route handler)
// app/api/razorpay/webhook/route.ts
import crypto from "crypto"
import { NextRequest, NextResponse } from "next/server"
export async function POST(req: NextRequest) {
const body = await req.text() // read raw body — do NOT parse as JSON first
const signature = req.headers.get("x-razorpay-signature")
const secret = process.env.RAZORPAY_WEBHOOK_SECRET!
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex")
if (signature !== expected) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
const event = JSON.parse(body)
if (event.event === "payment.captured") {
// safe to process — signature verified
await handlePaymentCaptured(event.payload.payment.entity)
}
return NextResponse.json({ received: true })
}Critical: you must read the raw body as text before verifying. If you call req.json() first, Next.js re-serialises the body and the signature will never match.
Express / Node.js
// Use express.raw() for the webhook route — NOT express.json()
app.post(
"/api/razorpay/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-razorpay-signature"]
const secret = process.env.RAZORPAY_WEBHOOK_SECRET
const expected = crypto
.createHmac("sha256", secret)
.update(req.body) // Buffer, not string
.digest("hex")
if (signature !== expected) {
return res.status(400).json({ error: "Invalid signature" })
}
const event = JSON.parse(req.body.toString())
// process event...
res.json({ received: true })
}
)Getting your webhook secret
- Go to Razorpay Dashboard → Settings → Webhooks
- Click your webhook URL → the secret is shown (or regenerate one)
- Add it to your environment variables:
RAZORPAY_WEBHOOK_SECRET=whsec_... - Deploy and test with Razorpay's "Test Webhook" button — you should see a 200 response
Additional hardening
- Idempotency: Store processed payment IDs and skip duplicates. Razorpay may retry webhooks if your server returns a non-200 response.
- Verify payment status: After receiving a webhook, call Razorpay's API to confirm the payment status independently — don't trust the webhook payload alone.
- Separate route: Keep your webhook route separate from your user-facing API — use a dedicated
/api/webhooks/razorpaypath. - Rotate the secret: If you accidentally commit your webhook secret, rotate it immediately in the Razorpay dashboard.
Vezraa checks if your payment webhook verifies signatures.
Start Scanning →