If you're shipping a paid product in India in 2026, you'll almost certainly take payments through Razorpay. The docs are good but spread across many pages, and most tutorials online cover the happy path and skip the parts that bite you in production — signature verification, webhook idempotency, refund handling. This guide ships a real integration end-to-end in Next.js, and calls out every gotcha we've seen across dozens of client integrations.
Stack assumed
Next.js 16 App Router + TypeScript, Razorpay Standard Checkout (the hosted modal). The same backend code works with any frontend.
1. The end-to-end flow you're about to build
┌──────────┐ ┌──────────┐ ┌────────────┐
│ Browser │ │ Your API │ │ Razorpay │
└────┬─────┘ └────┬─────┘ └─────┬──────┘
│ │ │
│ POST /create-order │ │
├───────────────────►│ orders.create() │
│ ├─────────────────────►│
│ │◄─────────────────────┤ { id: order_xxx }
│◄───────────────────┤ │
│ { orderId, amount }│ │
│ │ │
│ Open Checkout (Razorpay modal) │
├──────────────────────────────────────────►│
│ │ │
│ User pays in the modal ─ Razorpay returns│
│◄──────────────────────────────────────────┤
│ { paymentId, orderId, signature } │
│ │ │
│ POST /verify │ │
├───────────────────►│ verify signature │
│ │ (HMAC-SHA256) │
│◄───────────────────┤ ok / fail │
│ │ │
│ │◄─────────────────────┤ Webhook (async)
│ │ payment.captured │
│ │ → fulfil order │Two things happen on a successful payment: (1) the browser gets a signed response you verify synchronously, (2) Razorpay sends a webhook you also verify, asynchronously. You need both. The synchronous verification is for UX (show success page). The webhook is the source of truth (fulfil the order). Skipping the webhook is the #1 reason "my payment succeeded but the user didn't get access" bugs happen.
2. Get your keys
- Sign up at
razorpay.comand complete KYC (PAN, business proof, bank account) - Dashboard → Settings → API Keys → Generate test keys
- Store as
RAZORPAY_KEY_ID(public) andRAZORPAY_KEY_SECRET(server-only) - Webhook secret: Dashboard → Settings → Webhooks → Create webhook → use a random 32-char string as the secret. Store as
RAZORPAY_WEBHOOK_SECRET
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET=a-long-random-string
# Only the KEY_ID needs to be exposed to the browser
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxx3. Install the SDK
pnpm add razorpay
# or: npm i razorpay4. Create the order on the server
import { NextResponse } from "next/server";
import Razorpay from "razorpay";
import { z } from "zod";
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
const Body = z.object({
amount: z.number().int().positive(), // in PAISE (₹1 = 100)
receipt: z.string().max(40), // your internal order id
});
export async function POST(req: Request) {
const parsed = Body.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const { amount, receipt } = parsed.data;
// ALWAYS compute the amount on the server. Never trust the client.
const order = await razorpay.orders.create({
amount,
currency: "INR",
receipt,
notes: { source: "web-checkout" },
});
return NextResponse.json({
orderId: order.id,
amount: order.amount,
currency: order.currency,
});
}Most-common mistake on this step
Letting the browser tell you how much to charge. Always look up the price on the server from your DB / Stripe-like catalog. Never trust amounts from the client — it's a one-line change that prevents an existential bug.
5. Open the Checkout modal on the client
"use client";
import Script from "next/script";
import { useState } from "react";
declare global {
interface Window {
Razorpay: new (options: RazorpayOptions) => { open: () => void };
}
}
type RazorpayOptions = {
key: string;
amount: number;
currency: string;
name: string;
order_id: string;
handler: (resp: {
razorpay_payment_id: string;
razorpay_order_id: string;
razorpay_signature: string;
}) => void;
prefill?: { name?: string; email?: string; contact?: string };
theme?: { color?: string };
};
export default function PayButton({
amountPaise,
receipt,
}: {
amountPaise: number;
receipt: string;
}) {
const [loading, setLoading] = useState(false);
async function handlePay() {
setLoading(true);
try {
const r = await fetch("/api/payments/create-order", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ amount: amountPaise, receipt }),
});
const { orderId, amount, currency } = await r.json();
const rzp = new window.Razorpay({
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID!,
amount,
currency,
name: "CruxBit",
order_id: orderId,
theme: { color: "#A855F7" },
handler: async (resp) => {
const verify = await fetch("/api/payments/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(resp),
});
if (verify.ok) window.location.href = "/thank-you";
else window.location.href = "/payment-failed";
},
});
rzp.open();
} finally {
setLoading(false);
}
}
return (
<>
<Script src="https://checkout.razorpay.com/v1/checkout.js"
strategy="afterInteractive" />
<button onClick={handlePay} disabled={loading}>
{loading ? "Loading…" : "Pay now"}
</button>
</>
);
}6. Verify the signature on the server
import { NextResponse } from "next/server";
import crypto from "node:crypto";
export async function POST(req: Request) {
const {
razorpay_order_id,
razorpay_payment_id,
razorpay_signature,
} = await req.json();
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${razorpay_order_id}|${razorpay_payment_id}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(razorpay_signature, "hex")
);
if (!ok) {
return NextResponse.json({ ok: false }, { status: 400 });
}
// Mark the order as "verified" in your DB.
// Do NOT fulfil here yet — wait for the webhook.
return NextResponse.json({ ok: true });
}Use timingSafeEqual, always
A plain === lets attackers brute-force the signature one byte at a time via timing differences. crypto.timingSafeEqual is constant-time. Same lesson applies to every HMAC verification, not just Razorpay.
7. Handle the webhook (the real source of truth)
import { NextResponse } from "next/server";
import crypto from "node:crypto";
// IMPORTANT: read the raw body for signature verification.
// Don't use req.json() before verifying — re-serialised JSON
// can differ from what Razorpay signed.
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get("x-razorpay-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(raw)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
);
if (!ok) return NextResponse.json({ ok: false }, { status: 400 });
const event = JSON.parse(raw);
// Idempotency: store event.id and skip if seen before.
// (Razorpay retries up to 24 hours.)
if (await alreadyProcessed(event.id)) {
return NextResponse.json({ ok: true });
}
switch (event.event) {
case "payment.captured": {
const payment = event.payload.payment.entity;
await fulfilOrder({
orderId: payment.order_id,
paymentId: payment.id,
amount: payment.amount,
});
break;
}
case "payment.failed":
await markFailed(event.payload.payment.entity.order_id);
break;
case "refund.processed":
await markRefunded(event.payload.refund.entity);
break;
}
await recordProcessed(event.id);
return NextResponse.json({ ok: true });
}
// Stubs — wire to your DB
async function alreadyProcessed(_id: string) { return false; }
async function recordProcessed(_id: string) { /* … */ }
async function fulfilOrder(_p: { orderId: string; paymentId: string;
amount: number }) { /* … */ }
async function markFailed(_orderId: string) { /* … */ }
async function markRefunded(_r: unknown) { /* … */ }Configure the webhook URL in the Razorpay dashboard (Settings → Webhooks). Subscribe to payment.captured, payment.failed, refund.processed at minimum. Use the secret you saved in RAZORPAY_WEBHOOK_SECRET.
8. Refunds
// Server-only utility
import Razorpay from "razorpay";
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
export async function refundPayment(paymentId: string, amountPaise: number) {
return razorpay.payments.refund(paymentId, {
amount: amountPaise, // omit for full refund
speed: "optimum", // or "normal"
notes: { reason: "customer-request" },
});
}9. Testing in dev — local webhooks
Razorpay can't hit your localhost. Use a tunnel:
# Cloudflare tunnel (free, no signup)
npx cloudflared tunnel --url http://localhost:3000
# or ngrok if you prefer
ngrok http 3000
# Take the HTTPS URL and set it as your webhook URL in the dashboard
# while you're testing.Razorpay also gives you a "Test webhook" button in the dashboard for each event type — much faster than triggering real test payments.
10. Go-live checklist
- 1Switch keys from
rzp_test_*torzp_live_*in production env - 2Re-create the webhook in the live mode (test webhooks don't carry over)
- 3Enable only the payment methods you actually support (Dashboard → Payment Methods)
- 4Set descriptive billing names — they show on customer card statements
- 5Test a real ₹1 transaction end-to-end on production before announcing the launch
- 6Wire payment failures to a recoverable UX (don't drop the user on a generic error)
- 7Add monitoring on the webhook endpoint (Sentry/Honeybadger) — silent webhook failures = silent fulfillment failures
11. Gotchas we keep seeing
- Amount in rupees vs paise. Razorpay always uses paise. ₹500 = 50000. Send
500by mistake and you'll charge ₹5 - Trusting the client's amount. Look up the canonical price on the server, every time
- Forgetting webhook idempotency. Razorpay retries; without dedup you can fulfil an order twice
- Using
req.json()before signature verification. Verify against the raw body. Re-serialised JSON can differ byte-for-byte from what was signed - Logging the full webhook payload to a public log sink. Payloads include cardholder name, contact, email. Treat as PII
- Not handling the "user closed modal" state. Razorpay doesn't fire
handlerif the user dismisses; listen torzp.on("payment.failed", …)or just let the webhook be your source of truth
TL;DR
- Two endpoints:
/create-order(server) and/verify(server) - Plus a webhook handler — it's the source of truth, not the modal callback
- Always verify signatures with
crypto.timingSafeEqual; verify webhooks against the raw body - Amounts in paise, computed on the server, never trusted from the client
- Idempotent webhook processing (dedup on
event.id) - Test with a tunnel and the dashboard's "Test webhook" button before going live
Wiring Razorpay into a SaaS or marketplace and want a second pair of eyes on the security / refund / dispute flow? Drop us a paragraph about what you're building — payment integrations are one of our most common engagements and we'll happily send back a candid review.