Tutorial

How to integrate Razorpay into your website (Next.js, 2026)

Razorpay is the default payments stack for India in 2026. This guide ships a working integration end-to-end — order, checkout, verify, webhook, refund — with the security details most tutorials skip.

May 17, 202614 min readCruxBit Team

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

Razorpay flow
┌──────────┐         ┌──────────┐          ┌────────────┐
│ 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.com and complete KYC (PAN, business proof, bank account)
  • Dashboard → Settings → API Keys → Generate test keys
  • Store as RAZORPAY_KEY_ID (public) and RAZORPAY_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
.env.local
bash
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_xxxxxxxxxx

3. Install the SDK

bash
pnpm add razorpay
# or: npm i razorpay

4. Create the order on the server

src/app/api/payments/create-order/route.ts
typescript
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

src/components/PayButton.tsx
typescript
"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

src/app/api/payments/verify/route.ts
typescript
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)

src/app/api/payments/webhook/route.ts
typescript
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

typescript
// 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:

bash
# 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

  1. 1Switch keys from rzp_test_* to rzp_live_* in production env
  2. 2Re-create the webhook in the live mode (test webhooks don't carry over)
  3. 3Enable only the payment methods you actually support (Dashboard → Payment Methods)
  4. 4Set descriptive billing names — they show on customer card statements
  5. 5Test a real ₹1 transaction end-to-end on production before announcing the launch
  6. 6Wire payment failures to a recoverable UX (don't drop the user on a generic error)
  7. 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 500 by 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 handler if the user dismisses; listen to rzp.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.

#Razorpay#Payments#Next.js#India#Tutorial

Have a project?

Building something we've just written about?

Drop us a line. We respond within 24 hours with a candid, no-pressure take on whether we're the right partner.