Skip to content

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.captured event → 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

  1. Go to Razorpay Dashboard → Settings → Webhooks
  2. Click your webhook URL → the secret is shown (or regenerate one)
  3. Add it to your environment variables: RAZORPAY_WEBHOOK_SECRET=whsec_...
  4. 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/razorpay path.
  • 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 →

Related articles

Razorpay Webhook Security: Signature Verification | Vezraa