Razorpay is the recommended provider for SaaS products targeting India. It supports UPI, Indian cards, NetBanking, and other local payment methods while providing subscription billing through Razorpay Plans.
After completing this guide, your application will be ready to accept subscription payments through Razorpay and automatically synchronize customer subscription data.
Before you begin, create a Razorpay account and complete any required account verification.
You should have:
The free Starter tier does not require a Razorpay plan. Only paid plans need
Razorpay plans.
Set the payment provider in your environment file. You probably already did it in the Payments page.
PAYMENT_PROVIDER=razorpayCreate one Razorpay plan for each paid billing cycle in your application. With the default Oolvay pricing structure, you will create four plans: Pro Monthly, Pro Yearly, Business Monthly, and Business Yearly.
Navigate to the Razorpay Subscriptions page:
0 / 2,000 characters
https://dashboard.razorpay.com/app/subscriptionsClick the Plans tab and in it, the New Plan button.
In the plan creation panel:
Pro Monthly (or any distinctive name you prefer) as the plan name.1 Month.Repeat the entire process for remaining tiers, Pro Yearly, Business Monthly, and Business Yearly.
Most SaaS products offer a discount for annual billing in exchange for a longer commitment.
For example:
| Plan | Price |
|---|---|
| Pro Monthly | ₹100/month |
| Pro Yearly | ₹960/year |
In this example, customers receive a discount by paying annually while you benefit from improved retention and upfront revenue.
Each Razorpay plan has a unique ID beginning with plan_.
Navigate to the Razorpay Subscriptions page:
https://dashboard.razorpay.com/app/plansAnd locate the plan that says
Pro Monthly (or your chosen name, if different) in the Plan Name column.
Click on the corresponding Plan Id value. In the panel that opens, copy the title.
Do not click the button next to the plan ID in the panel title. It creates a duplicate plan and does not copy the ID to your clipboard. Copy the plan ID manually.
Paste the IDs into config/pricing.ts:
export const PRICE_MAP = {
pro_monthly: {
razorpay: "plan_xxxxxxxxx",
},
pro_yearly: {
razorpay: "plan_xxxxxxxxx",
},
business_monthly: {
razorpay: "plan_xxxxxxxxx",
},
business_yearly: {
razorpay: "plan_xxxxxxxxx",
},
}Open Razorpay dashboard and navigate to Account & Settings → API keys. Or directly visit:
https://dashboard.razorpay.com/app/website-app-settings/business-website-detailsClick Generate Key to create a new set of API keys. This will generate a Key ID and a Key Secret.
Add both values to your .env.local:
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=
NEXT_PUBLIC_RAZORPAY_KEY_ID=NEXT_PUBLIC_RAZORPAY_KEY_ID should contain the same value as
RAZORPAY_KEY_ID. It is required because Razorpay checkout runs in the
browser.
Razorpay requires a publicly accessible URL to deliver webhooks. During local development, your localhost server is not reachable from the internet, so you need a tunneling tool like ngrok to expose it temporarily.
In your terminal, run:
ngrok http 3000Once running, ngrok will give you a temporary public domain like https://your-ngrok-domain.ngrok-free.app. Your full webhook URL will be:
<your-ngrok-domain>/api/payments/webhooks/razorpay
In your Razorpay dashboard, navigate to Account & Settings → Webhooks and click Add New Webhook. A Webhook Setup dialog will open.
Enter the URL from the previous step into the Webhook URL field. Then enter a secure random string in the Secret field. You can generate one with:
openssl rand -hex 32or:
node -e "console.log(crypto.randomBytes(32).toString('hex'))"Make a note of the generated string for later.
In the Active Events section, enable the following items:
payment.failedpayment.capturedsubscription.activatedsubscription.chargedsubscription.cancelledsubscription.completedsubscription.updatedrefund.createdClick Create Webhook.
Add the generated webhook secret to your .env.local:
RAZORPAY_WEBHOOK_SECRET=When you deploy to production, remember to update the webhook callback URL in
your Lemon Squeezy dashboard from your ngrok URL to your real domain, such as
https://acme.com/api/payments/webhooks/razorpay.
Unlike Lemon Squeezy, Razorpay does not provide a hosted customer billing portal.
Oolvay includes its own billing page at /settings/billing.
Customers can:
Razorpay cancellations take effect immediately. Customers do not retain access until the end of the billing period after cancellation.
Your configuration should now resemble:
PAYMENT_PROVIDER=razorpay
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=
RAZORPAY_WEBHOOK_SECRET=
NEXT_PUBLIC_RAZORPAY_KEY_ID=Make sure ngrok is running before starting the application:
ngrok http 3000Start the application:
bun devOn Razorpay’s mock bank page, enter any OTP between 4 and 10 digits to simulate a successful payment. Enter an OTP shorter than 4 digits to simulate a failure.
Open the pricing page and attempt a checkout using one of the following test cards:
| Type | Network | Card Number |
|---|---|---|
| Domestic | Visa Credit | 4718 6091 0820 4366 |
| International | Mastercard Credit | 5104 0155 5555 5558 |
| International | Mastercard Debit | 5104 0600 0000 0008 |
Use any future expiry date, any CVV, and any OTP containing 4 to 10 digits.
In test mode, card tokens for subscriptions are valid for 3 days only. If you need to test a subsequent charge, trigger it within 3 days of token creation.
Razorpay test mode automatically simulates a successful payment.
When the QR code appears, just click View QR Code and wait for the flow to complete. You do not need to enter a dummy UPI ID.
To verify payment failure handling, use one of these cards. After initiating payment, choose the failure path on Razorpay’s mock bank page.
| Scenario | Network | Card Number |
|---|---|---|
payment_timed_out | Visa | 4100 2800 0009 0000 |
payment_timed_out | Mastercard | 5305 6200 0006 0000 |
insufficient_fund | Visa | 4100 2800 0008 0001 |
insufficient_fund | Mastercard | 5305 6200 0005 0001 |
payment_cancelled | Visa | 4100 2800 0007 0002 |
payment_cancelled | Mastercard | 5305 6200 0004 0002 |
card_declined | Visa | 4100 2800 0006 0003 |
card_declined | Mastercard | 5305 6200 0003 0003 |
card_declined | Visa | 4100 2800 0005 0004 |
card_declined | Mastercard | 5305 6200 0002 0004 |
card_declined | Visa | 4100 2800 0004 0005 |
card_declined | Mastercard | 5305 6200 0001 0005 |
card_disabled_for_online_payments | Visa | 4100 2800 0003 0006 |
card_disabled_for_online_payments | Mastercard | 5305 6200 0000 0006 |
card_number_invalid | Visa | 4100 2800 0001 0008 |
card_number_invalid | Mastercard | 5305 6200 0008 0008 |
gateway_technical_error | Visa | 4100 2800 0002 0007 |
gateway_technical_error | Mastercard | 5305 6200 0009 0007 |
authentication_failed | Visa | 4100 2800 0000 0009 |
authentication_failed | Mastercard | 5305 6200 0007 0009 |
After a successful test payment, confirm that:
/settings/billingThe checkout button calls POST /api/payments/checkout when clicked. If nothing happens, open your browser’s developer console and check for errors.
PAYMENT_PROVIDER is not set or is set incorrectly.
Open .env.local and confirm:
PAYMENT_PROVIDER=razorpayRestart the development server after any changes to .env.local.
A required API key is missing.
Confirm all three values are present in .env.local:
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=
NEXT_PUBLIC_RAZORPAY_KEY_ID=NEXT_PUBLIC_RAZORPAY_KEY_ID is required specifically because the Razorpay modal runs in the browser. If it is missing, the modal will silently fail to initialise.
A plan ID in PRICE_MAP does not exist in Razorpay.
If the plan ID is wrong or points to a deleted plan, the checkout request will fail. Re-verify each ID by following the steps in the Collect plan IDs section.
The Razorpay script failed to load.
The modal depends on https://checkout.razorpay.com/v1/checkout.js being loaded dynamically at checkout time. If you have a strict Content Security Policy, ensure that domain is whitelisted. Check the browser console for script load errors.
NEXT_PUBLIC_RAZORPAY_KEY_ID does not match RAZORPAY_KEY_ID.
Both must contain the same value. A mismatch causes the modal to open but fail silently when attempting to process the payment.
The /api/payments/checkout/verify endpoint verifies the HMAC signature sent by Razorpay after a successful modal payment. A 401 response means the signature check failed.
The most common cause is a mismatch between the razorpay_order_id and razorpay_payment_id values passed to the endpoint. Confirm that your CheckoutButton is forwarding all three values from the Razorpay handler response exactly as received:
razorpay_payment_idrazorpay_order_idrazorpay_signatureDo not modify or re-encode any of these values before sending them to the verify endpoint.
This means webhooks are not reaching your application. The payment succeeded on Razorpay’s side but your database was never notified.
Check recent deliveries in Razorpay. Navigate to Account & Settings → Webhooks in your Razorpay dashboard. Click on your webhook endpoint and inspect recent deliveries. Failed deliveries will show the HTTP response code your server returned. This is the fastest way to diagnose the problem.
ngrok is not running. During local development, webhooks require ngrok to be running in a separate terminal. If ngrok is not running or has restarted with a new URL, Razorpay cannot reach your application. Restart ngrok and update the Webhook URL in your Razorpay webhook settings with the new URL.
The webhook URL is wrong. Confirm that the URL in your Razorpay webhook settings matches exactly:
https://your-ngrok-or-domain.com/api/payments/webhooks/razorpay
A common mistake is using /api/webhooks/razorpay instead of /api/payments/webhooks/razorpay.
The webhook secret does not match.
If Razorpay is delivering webhooks but your application is rejecting them with a 400 response, the webhook secret is mismatched. The value in RAZORPAY_WEBHOOK_SECRET must be identical to the secret you entered when creating the webhook in Razorpay. Secrets are case-sensitive. Re-generate and re-enter both if you are unsure.
The payment succeeded and the webhook was delivered, but the tier did not update. Check the webhook_events table for rows with status = 'failed'. Open Drizzle Studio to inspect it:
bun db:studioFind the subscription.activated event for the affected user. If status = 'failed', the error column will describe what went wrong. The most common causes are a missing userId in the checkout metadata or a database write failure.
A plan ID in PRICE_MAP is pointing to the wrong plan in your Razorpay dashboard. For example, the pro_monthly key might be mapped to the business_monthly plan ID. Re-verify each plan ID by following the Collect plan IDs section and cross-checking each ID against the correct plan in Razorpay.
Oolvay automatically attempts to cancel older active subscriptions when a new one is created. If duplicates still appear, check the webhook_events table for failed subscription.activated processing. A failure during that handler can prevent the cancellation logic from running.
Your application returns 400 to Razorpay when the HMAC signature on an incoming webhook does not match. Confirm that:
RAZORPAY_WEBHOOK_SECRET=is identical to the secret configured in your Razorpay webhook settings. Secrets are case-sensitive. If you are unsure they match, delete the webhook in Razorpay, generate a new secret, recreate the webhook, and update .env.local.
Required webhook events are disabled. Confirm that all of the following events are enabled in your Razorpay webhook settings:
subscription.cancelledsubscription.completedsubscription.updatedIf any of these are disabled, Oolvay cannot synchronise subscription state changes.
Note on cancellation timing.
Unlike Lemon Squeezy, Razorpay cancellations take effect immediately. There is no grace period. Access is revoked as soon as the subscription.cancelled webhook is processed. This is expected behaviour, not a bug.