**Quick answer:** Razorpay Subscriptions in 2026 take ~2 hours to wire end-to-end for a Next.js + Supabase + INR SaaS. Create Plans in the dashboard, call `POST /subscriptions` server-side, open Checkout with the returned short_url, verify webhook signatures, update your DB on lifecycle events. Below is the exact playbook we use at StackPicks and AutoDM — every step verified live in production.
Already comparing payment providers? See **Razorpay vs Stripe for Indian SaaS** first to confirm Razorpay is the right pick for your product.
Prerequisites
- Razorpay account (sign up at razorpay.com — KYC takes 1-3 days)
- Test Mode keys (you can build the entire flow before activation)
- A backend that can call Razorpay's REST API (Node, Next.js Route Handlers, Python, anything)
- A way to receive webhooks (a public HTTPS URL — Vercel / Railway / fly.io all work)
Step 1 — Create your Plans in the Razorpay dashboard
Plans are templates that define billing frequency + amount + currency. Dashboard → Subscriptions → Plans → Create.
For each tier × billing cycle, create one Plan. Example for a ₹499 / ₹4,999 SaaS:
| Plan name | Billing | Amount | Plan ID |
|---|---|---|---|
| creator_monthly | Monthly | 499 INR (49900 paise) | plan_ABC123 |
| creator_yearly | Yearly | 4990 INR (499000 paise) | plan_ABC124 |
| pro_monthly | Monthly | 1499 INR | plan_DEF456 |
| pro_yearly | Yearly | 14990 INR | plan_DEF457 |
Save the `plan_*` IDs — you'll reference them in env vars. Note: Razorpay stores amounts in paise (1 INR = 100 paise). This is the #1 bug people hit. 49900 = ₹499.
Step 2 — Environment setup
# .env.local
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_XXXXXXXXXXXXX
RAZORPAY_KEY_SECRET=YYYYYYYYYYYYYYYYYYYY
RAZORPAY_WEBHOOK_SECRET=ZZZZZZZZZZZZZZZZZZZZ
# Plan IDs from Step 1
RAZORPAY_PLAN_CREATOR_MONTHLY=plan_ABC123
RAZORPAY_PLAN_CREATOR_YEARLY=plan_ABC124
RAZORPAY_PLAN_PRO_MONTHLY=plan_DEF456
RAZORPAY_PLAN_PRO_YEARLY=plan_DEF457Step 3 — Create the subscription server-side
User clicks "Subscribe" → your backend creates a Subscription on Razorpay → returns the short_url for Checkout.
// app/api/checkout/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { plan_id, user_id } = await req.json();
const auth = Buffer.from(
`${process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}`
).toString('base64');
const res = await fetch('https://api.razorpay.com/v1/subscriptions', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
plan_id,
customer_notify: 1,
total_count: 60, // 60 months of monthly subs; effectively "long lived"
notes: { user_id }, // travels with the subscription, useful in webhooks
}),
});
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: 502 });
}
const sub = await res.json();
return NextResponse.json({ short_url: sub.short_url, subscription_id: sub.id });
}Step 4 — Open Checkout on the client
Redirect the user to short_url or use Razorpay's JS SDK. Simpler is window.location.href = short_url — Razorpay handles the whole authorization flow (UPI Autopay or Card Mandate) and redirects back when done.
Step 5 — Webhook signature verification (CRITICAL)
Never trust the client to confirm payment. The browser can lie. The webhook is the truth.
In Razorpay dashboard → Settings → Webhooks → Add webhook. URL: https://yourapp.com/api/webhook/razorpay. Events: subscription.activated, subscription.charged, subscription.cancelled, subscription.completed, payment.captured, payment.failed. Generate a Secret — save it as RAZORPAY_WEBHOOK_SECRET.
// app/api/webhook/razorpay/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
export const runtime = 'nodejs';
function verifySignature(rawBody: string, signature: string): boolean {
const secret = process.env.RAZORPAY_WEBHOOK_SECRET!;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return false; // CRITICAL — prevents timingSafeEqual throw
return crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const sig = req.headers.get('x-razorpay-signature') || '';
const raw = await req.text();
if (!verifySignature(raw, sig)) {
return NextResponse.json({ error: 'bad signature' }, { status: 400 });
}
const event = JSON.parse(raw) as { event: string; payload: { subscription?: { entity: any } } };
switch (event.event) {
case 'subscription.activated':
case 'subscription.charged': {
const sub = event.payload.subscription!.entity;
// mark user_id (from notes) as paid through current_end
// await supabase.from('subscriptions').upsert({ ... })
break;
}
case 'subscription.cancelled':
case 'subscription.completed': {
// mark user as past_due / churned
break;
}
}
return NextResponse.json({ ok: true });
}Step 6 — The gotchas
The "Subscription Approved but no webhook" trap. If your webhook URL is wrong or returns non-200, Razorpay retries 5 times over 24 hours and gives up. Always log every webhook hit, even failed signature ones — saves you when debugging.
The buffer-length crash. crypto.timingSafeEqual THROWS if the two buffers have different lengths (instead of returning false). If someone sends x-razorpay-signature: fake, your endpoint 500s. Always check a.length !== b.length first.
The amount-in-paise bug. Storing ₹499 as 499 in your DB and 49900 in the Razorpay Plan means your invoices show ₹4.99. We store everything as paise internally and format with (paise / 100).toLocaleString('en-IN') for display.
The "test mode subscriptions don't auto-charge" surprise. Test mode renewals don't fire automatically — you have to use Razorpay's test API to simulate them. Don't get spooked when you don't see the 2nd month's charge in test.
The webhook secret rotation problem. If you rotate the webhook secret in Razorpay dashboard, you need a deploy with the new env var BEFORE the next webhook fires. We had a 2-hour outage from this once. Lesson: deploy first, rotate secret in dashboard second.
Step 7 — Test thoroughly before going live
In Test Mode:
- Subscribe with the test card
4111 1111 1111 1111(any CVV, any future expiry) - Confirm webhook fires (check your server logs)
- Confirm DB row created with correct
current_period_end - Manually trigger
subscription.chargedevent from Razorpay dashboard → simulates renewal - Cancel from the dashboard → confirm
subscription.cancelledwebhook updates your DB
Only after this works flawlessly should you swap to Live Mode keys.
Going live checklist
- [ ] KYC approved in Razorpay
- [ ] Live Mode keys in Railway / Vercel env vars
- [ ] Webhook URL updated in Live Mode dashboard (it's separate from Test)
- [ ] One real ₹1 transaction tested end-to-end
- [ ] Refund flow tested (refund the ₹1 — confirm webhook + DB update)
- [ ] Auto-DM / email confirmation sent on
subscription.activated - [ ] Past-due UI surfaces when webhook reports failed renewal
What we use at StackPicks
This exact setup runs both StackPicks lifetime memberships and the upcoming AutoDM subscriptions. Test-mode-to-live-mode swap was zero code changes — just env vars. If you want the broader stack (Supabase + Next.js + Razorpay glue), that's the Ship-a-SaaS bundle.
TL;DR
- Create Plans in dashboard
POST /subscriptionsserver-side withplan_id+ user_id in notes- Open short_url for Checkout
- Verify webhook signatures BEFORE trusting any payload
- Use
notes.user_idfrom the webhook to update your DB - Test everything in Test Mode first
- Go Live, monitor first 10 transactions hawkishly
This pattern is battle-tested and ships in ~2 hours. Razorpay's DX in 2026 is genuinely good — don't overthink it.