stackpicks.dev
All posts
Razorpay Subscription Setup for Indian SaaS in 2026: Complete Guide
Payments·11 min read

Razorpay Subscription Setup for Indian SaaS in 2026: Complete Guide

Step-by-step setup for Razorpay Subscriptions in Next.js + Supabase — Plan creation, e-mandate flow, webhook verification, signature checks, and the gotchas nobody warns you about.

Piyush Jangir
Verified author

Founder of StackPicks. Self-taught builder shipping open-source dev tools, marketing, and curator content since 2019. Based in Mumbai, India. Available on GitHub and LinkedIn.

11 min read
Quick answer
To set up Razorpay Subscriptions for an Indian SaaS in 2026: (1) create Plans in the Razorpay dashboard, (2) server-side call POST /subscriptions with plan_id, (3) open the returned short_url in Razorpay Checkout, (4) verify webhook signatures server-side and update your DB on subscription.activated, subscription.charged, subscription.cancelled events. Full setup takes ~2 hours including testing.

**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 nameBillingAmountPlan ID
creator_monthlyMonthly499 INR (49900 paise)plan_ABC123
creator_yearlyYearly4990 INR (499000 paise)plan_ABC124
pro_monthlyMonthly1499 INRplan_DEF456
pro_yearlyYearly14990 INRplan_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_DEF457

Step 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:

  1. Subscribe with the test card 4111 1111 1111 1111 (any CVV, any future expiry)
  2. Confirm webhook fires (check your server logs)
  3. Confirm DB row created with correct current_period_end
  4. Manually trigger subscription.charged event from Razorpay dashboard → simulates renewal
  5. Cancel from the dashboard → confirm subscription.cancelled webhook 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

  1. Create Plans in dashboard
  2. POST /subscriptions server-side with plan_id + user_id in notes
  3. Open short_url for Checkout
  4. Verify webhook signatures BEFORE trusting any payload
  5. Use notes.user_id from the webhook to update your DB
  6. Test everything in Test Mode first
  7. 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.

Frequently asked questions

Do I need a registered business to use Razorpay Subscriptions?+

You need at minimum a sole proprietorship + PAN. Razorpay KYC accepts PAN + Aadhaar + bank account for individuals. A registered entity (LLP, Pvt Ltd) gets you better settlement terms and higher transaction limits, but you can start as an individual and upgrade later without re-integration.

How long does Razorpay KYC take in 2026?+

For individuals with PAN + Aadhaar: 24-48 hours. For Pvt Ltd: 3-5 working days. Use Test Mode keys to build and validate the entire flow before activation — your code does not change when you flip to Live keys.

What is e-mandate and why does it matter?+

RBI 2021 auto-debit rules require explicit customer authorization for every recurring debit above ₹15,000. Razorpay handles this via UPI Autopay (one-time mandate, auto-renews indefinitely under the cap) or Card Mandate (similar). Without e-mandate, you cannot legally auto-charge Indian customers monthly — manual reauth would kill SaaS UX.

How do I handle failed renewals?+

Razorpay automatically retries failed charges over 3-5 days (configurable per Plan). You receive subscription.charged events on success and subscription.charge.failed on each failed attempt. Standard practice: after 2 failures, email the user; after final failure, set their account to "past_due" via webhook and show an in-app banner with a "Update payment method" CTA.

Can I let users pick monthly OR yearly?+

Yes. Create two Plans in the dashboard (monthly_basic, yearly_basic). When creating the subscription server-side, use the plan_id the user picked. Standard pricing: yearly = 10× monthly (2 months free) is what most Indian SaaS users expect.

More in Payments

Razorpay Subscription Setup for Indian SaaS in 2026: Complete Guide — StackPicks — StackPicks