Table of Contents
If you are building a web application in India, Razorpay is almost certainly your payment gateway of choice. It supports UPI, credit and debit cards, net banking, wallets, and EMI - all through a single integration. And if you are building with Next.js, you get the advantage of server-side API routes for secure order creation and webhook handling, combined with a React frontend for a seamless checkout experience. This guide walks you through the entire integration from scratch, with working code snippets you can copy directly into your project.
Prerequisites
Before you begin, make sure you have the following in place:
- A Razorpay account. Sign up at razorpay.com. You will get test mode access immediately - no KYC required for testing. Full KYC is needed only when you want to accept real payments.
- API keys. Go to Settings → API Keys in your Razorpay Dashboard. Generate a key pair. You will get a Key ID (starts with
rzp_test_) and a Key Secret. The Key ID is public - it goes in your frontend. The Key Secret is private - it stays on your server only. - A Next.js project. This guide uses Next.js 14+ with the App Router. If you are on the Pages Router, the API route syntax differs slightly but the logic is identical.
- Node.js 18 or later. Required for Next.js 14 and the Razorpay Node SDK.
Add your Razorpay keys to your .env.local file. Never commit this file to version control.
RAZORPAY_KEY_ID=rzp_test_XXXXXXXXXXXXXX RAZORPAY_KEY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXX NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_XXXXXXXXXXXXXX
Note: The NEXT_PUBLIC_ prefix makes the key available in client-side code. Only expose the Key ID this way, never the secret.
Install the Razorpay Package
Razorpay provides an official Node.js SDK that handles order creation, payment verification, refunds, and more. Install it along with the crypto module type definitions (needed for webhook signature verification):
npm install razorpay npm install -D @types/razorpay
The SDK is lightweight - around 50KB unpacked - and has zero external dependencies. It communicates with Razorpay's REST API under the hood and handles authentication, request signing, and error parsing for you. You could technically make raw HTTP calls to the Razorpay API, but the SDK saves significant boilerplate and reduces the chance of authentication errors.
Create the Order API Route
The payment flow in Razorpay works like this: your server creates an "order" with a specific amount, Razorpay returns an order ID, your frontend uses that order ID to open the checkout modal, and after payment the frontend receives a payment ID and signature for verification. This server-first approach prevents amount tampering - the customer cannot modify the payment amount because it is locked server-side.
Create the API route at src/app/api/razorpay/route.ts:
import Razorpay from "razorpay";
import { NextRequest, NextResponse } from "next/server";
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
export async function POST(req: NextRequest) {
try {
const { amount, currency, receipt, notes } = await req.json();
// Validate amount (Razorpay expects paise, not rupees)
if (!amount || amount < 100) {
return NextResponse.json(
{ error: "Amount must be at least 100 paise (₹1)" },
{ status: 400 }
);
}
const order = await razorpay.orders.create({
amount: amount, // in paise: 50000 = ₹500
currency: currency || "INR",
receipt: receipt || `receipt_${Date.now()}`,
notes: notes || {},
});
return NextResponse.json({
orderId: order.id,
amount: order.amount,
currency: order.currency,
});
} catch (error: any) {
console.error("Razorpay order creation failed:", error);
return NextResponse.json(
{ error: error.message || "Order creation failed" },
{ status: 500 }
);
}
}A few important things to note here. The amount is always in the smallest currency unit - for INR, that means paise. So if you want to charge 500 rupees, you pass 50000. This is a common source of bugs - developers pass 500 thinking it means rupees, and the customer gets charged 5 rupees instead. Always multiply by 100 before sending.
The receipt field is your internal reference. Use your order ID from your database here so you can reconcile payments later. The notes field accepts up to 15 key-value pairs that are stored with the payment and visible in your Razorpay dashboard - useful for attaching customer IDs, product names, or any metadata you need for debugging.
Frontend Checkout Component
Razorpay provides a JavaScript SDK that opens a pre-built checkout modal. This modal handles all payment method selection, card input, UPI flow, OTP verification, and 3D Secure authentication. You do not need to build any payment UI yourself - and you should not, because handling raw card numbers would bring PCI DSS compliance requirements.
First, load the Razorpay script. The cleanest way in Next.js is to add it to your layout or use the Script component. Then create your checkout component:
"use client";
import Script from "next/script";
import { useState } from "react";
declare global {
interface Window {
Razorpay: any;
}
}
interface CheckoutProps {
amount: number; // in rupees (we convert to paise)
productName: string;
customerName: string;
customerEmail: string;
customerPhone: string;
}
export default function RazorpayCheckout({
amount,
productName,
customerName,
customerEmail,
customerPhone,
}: CheckoutProps) {
const [loading, setLoading] = useState(false);
const handlePayment = async () => {
setLoading(true);
try {
// Step 1: Create order on server
const res = await fetch("/api/razorpay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: amount * 100, // convert rupees to paise
currency: "INR",
receipt: `order_${Date.now()}`,
notes: { product: productName },
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Step 2: Open Razorpay checkout modal
const options = {
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
amount: data.amount,
currency: data.currency,
name: "Your Company Name",
description: productName,
order_id: data.orderId,
prefill: {
name: customerName,
email: customerEmail,
contact: customerPhone,
},
theme: { color: "#F97316" },
handler: function (response: any) {
// Payment successful — verify on server
verifyPayment(response);
},
modal: {
ondismiss: function () {
setLoading(false);
},
},
};
const rzp = new window.Razorpay(options);
rzp.open();
} catch (error) {
console.error("Payment initiation failed:", error);
alert("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
const verifyPayment = async (response: any) => {
const res = await fetch("/api/razorpay/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
}),
});
const data = await res.json();
if (data.verified) {
// Redirect to success page or update UI
window.location.href = "/payment/success";
} else {
alert("Payment verification failed.");
}
};
return (
<>
<Script
src="https://checkout.razorpay.com/v1/checkout.js"
strategy="lazyOnload"
/>
<button
onClick={handlePayment}
disabled={loading}
>
{loading ? "Processing..." : `Pay ₹${amount}`}
</button>
</>
);
}The flow here is straightforward. When the user clicks the pay button, we call our API route to create a Razorpay order. Once we get the order ID back, we instantiate the Razorpay checkout with the order details and open the modal. The handler callback fires when payment is successful, and we immediately send the response to our server for verification. Never trust the frontend alone - always verify server-side.
The prefill object pre-populates the customer's details in the checkout modal. This reduces friction significantly - the customer does not have to type their email or phone again. If you already have this information from a login or a form, always pass it here.
Webhook Verification
The client-side handler callback is useful for immediate UI updates, but it is not reliable for critical business logic. What if the customer closes the browser after payment but before your handler fires? What if there is a network error during the verification call? You would have received money but never updated your database.
This is why Razorpay webhooks are essential. Razorpay sends a POST request to your server whenever a payment event occurs - payment captured, payment failed, refund processed, and so on. This is a server-to-server call, completely independent of the customer's browser.
Create the verification API route and the webhook handler:
import crypto from "crypto";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const {
razorpay_order_id,
razorpay_payment_id,
razorpay_signature,
} = await req.json();
// Generate expected signature
const body = razorpay_order_id + "|" + razorpay_payment_id;
const expectedSignature = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(body)
.digest("hex");
// Compare signatures
const isValid = expectedSignature === razorpay_signature;
if (isValid) {
// Payment is verified — update your database
// await db.orders.update({
// where: { razorpayOrderId: razorpay_order_id },
// data: {
// status: "paid",
// paymentId: razorpay_payment_id,
// },
// });
return NextResponse.json({ verified: true });
} else {
return NextResponse.json(
{ verified: false, error: "Invalid signature" },
{ status: 400 }
);
}
} catch (error: any) {
return NextResponse.json(
{ verified: false, error: error.message },
{ status: 500 }
);
}
}Now create the webhook endpoint for server-to-server notifications:
import crypto from "crypto";
import { NextRequest, NextResponse } from "next/server";
const WEBHOOK_SECRET = process.env.RAZORPAY_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const signature = req.headers.get("x-razorpay-signature");
// Verify webhook signature
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(body)
.digest("hex");
if (expectedSignature !== signature) {
return NextResponse.json(
{ error: "Invalid webhook signature" },
{ status: 401 }
);
}
const event = JSON.parse(body);
switch (event.event) {
case "payment.captured":
const payment = event.payload.payment.entity;
// Update order status in your database
console.log("Payment captured:", payment.id);
break;
case "payment.failed":
const failedPayment = event.payload.payment.entity;
console.log("Payment failed:", failedPayment.id);
break;
case "refund.processed":
const refund = event.payload.refund.entity;
console.log("Refund processed:", refund.id);
break;
}
return NextResponse.json({ status: "ok" });
} catch (error: any) {
console.error("Webhook error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}The signature verification is critical. Razorpay signs every webhook payload with your webhook secret using HMAC SHA256. You must verify this signature before processing the event. Without this check, anyone could send fake payment notifications to your endpoint and trick your system into marking orders as paid.
To set up webhooks, go to your Razorpay Dashboard → Settings → Webhooks. Add your endpoint URL (for example, https://yourdomain.com/api/razorpay/webhook), set a webhook secret, and select the events you want to receive. At minimum, subscribe to payment.captured and payment.failed.
UPI, Cards, and EMI Support
One of Razorpay's biggest strengths is that all payment methods work through the same integration. You do not need separate code for UPI, cards, net banking, or wallets. The checkout modal handles everything. However, you can customise which methods appear and how they behave.
UPI Intent and Collect
UPI accounts for over 60% of digital payments in India. Razorpay supports both UPI Collect (where the customer enters their VPA and approves the payment in their UPI app) and UPI Intent (where the checkout directly opens the customer's UPI app on mobile). On desktop, a QR code is displayed that the customer scans with any UPI app. This works automatically - no additional configuration needed.
Card Payments and 3D Secure
All card payments in India require 3D Secure (OTP) authentication as per RBI mandate. Razorpay handles this seamlessly - the checkout modal redirects to the bank's OTP page and back. You do not need to handle any redirects or callbacks for this. International cards without 3D Secure support are also handled, but you need to enable international payments separately in your Razorpay dashboard.
EMI Options
For higher-value transactions, EMI (Equated Monthly Installments) can significantly improve conversion rates. Razorpay supports no-cost EMI and standard EMI across major banks. To enable EMI, add it to your checkout options:
const options = {
// ... other options
method: {
upi: true,
card: true,
netbanking: true,
wallet: true,
emi: true, // enable EMI options
},
// Minimum ₹3000 for EMI to appear
};Note that EMI is only available for transactions above a minimum amount (typically 3,000 INR) and the customer's bank must support it. Razorpay handles this automatically - if EMI is not available for a particular card, the option simply does not appear.
Testing with Test Keys
Razorpay provides a complete sandbox environment with test keys (prefixed with rzp_test_). In test mode, no real money is charged and you can simulate both successful and failed payments.
Test Card Numbers
- Successful payment: Card number
4111 1111 1111 1111, any future expiry date, any CVV, any OTP. - Failed payment: Use the "Fail" button that appears in the test mode checkout to simulate a declined payment.
- UPI test: Use VPA
success@razorpayfor successful payments andfailure@razorpayfor failures.
Testing Webhooks Locally
Razorpay webhooks need a publicly accessible URL, which your localhost is not. Use a tunnelling service to expose your local server:
# Using ngrok ngrok http 3000 # Copy the HTTPS URL and set it as your # webhook endpoint in Razorpay Dashboard
Test every scenario: successful payment, failed payment, user closing the modal before completing, network timeout during verification, and webhook delivery. Your application should handle all of these gracefully.
Common Errors and Fixes
After implementing Razorpay for dozens of clients, these are the errors we see most frequently:
Error: "The amount must be at least INR 1.00"
You are passing the amount in rupees instead of paise. If you want to charge 500 rupees, pass 50000 (500 x 100) as the amount. This is the single most common mistake in Razorpay integrations.
Error: "BAD_REQUEST_ERROR: The id provided does not exist"
You are mixing test and live keys. If you created an order with a test key, you must open the checkout with the same test key. Check that your RAZORPAY_KEY_ID in the API route and NEXT_PUBLIC_RAZORPAY_KEY_ID in the frontend are from the same key pair.
Error: "Payment verification failed"
The signature does not match. This usually happens when you use the wrong secret for verification. The payment verification uses the API Key Secret, while webhook verification uses the Webhook Secret. These are different values. Double-check which one you are using where.
Error: Checkout modal does not open
The Razorpay script has not loaded yet. Make sure the script tag is present and loaded before you try to instantiate new window.Razorpay(). If using the Next.js Script component with strategy="lazyOnload", add a check: if (typeof window.Razorpay === "undefined") and load the script dynamically if needed.
Error: Webhook not receiving events
Check three things. First, is your webhook URL correct and publicly accessible? Second, is your server returning a 200 status code? Razorpay retries failed webhooks, but if your endpoint consistently returns errors, it will stop sending. Third, check the webhook delivery logs in your Razorpay dashboard under Settings → Webhooks - it shows every delivery attempt and the response received.
Going Live Checklist
Before switching from test mode to live payments, go through this checklist:
- Complete KYC. Submit your business documents (PAN, GST certificate, bank account details, business proof) in the Razorpay dashboard. Approval typically takes 2 to 3 business days.
- Replace test keys with live keys. Generate live API keys from the Razorpay dashboard and update your environment variables. Live keys start with
rzp_live_. - Update webhook URLs. Point webhooks to your production URL, not localhost or ngrok. Generate a new webhook secret for production.
- Test with a real payment. Make a small real payment (1 rupee) and refund it immediately. Verify the entire flow works - order creation, payment, webhook delivery, and database update.
- Set up error monitoring. Use any reliable error tracking tool to catch payment failures in production. Payment errors need immediate attention.
- Enable HTTPS. Razorpay requires your site to be served over HTTPS. Most managed hosting platforms provide this automatically; for custom servers, ensure a valid SSL certificate is configured.
- Add a payment failure page. Do not just show an alert. Create a proper failure page that tells the customer what happened and gives them a clear way to retry.
- Configure refund handling. Plan your refund flow. Razorpay supports instant refunds for most payment methods, but they take 5 to 7 business days to reflect in the customer's account. Communicate this clearly.
Once you have checked every item on this list, you are ready to accept real payments. Monitor your Razorpay dashboard closely for the first week - watch for unusual failure rates, webhook delivery issues, or settlement delays. Razorpay settles payments to your bank account on a T+2 basis (2 business days after capture), so plan your cash flow accordingly.
Need Razorpay Integrated in Your App?
We build Next.js applications with Razorpay, Cashfree, and PhonePe integrations - subscriptions, split payments, marketplace payouts, and more.
Get Payment Integration Help