| name | customer-portal |
| description | Build a self-service billing portal — view subscription, download invoices, cancel, update payment method. Use when the user asks to "build a billing page", "create a customer portal", "add self-service billing", "let users manage subscriptions", or needs a customer-facing account settings page. |
| argument-hint | [billing-page|invoices|cancel] |
Self-Service Customer Billing Portal
Build a complete customer-facing billing portal with Razorpay. Unlike Stripe, Razorpay has no built-in customer portal — you build each piece yourself.
Covers: billing status, payment history, cancel/reactivate, invoice download, and payment method update.
1. Billing Status API Route
Returns the current subscription state for display on the billing page.
import { razorpay } from "@/lib/razorpay";
export async function GET(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
const subscription = await getActiveSubscriptionByUserId(user.id);
if (!subscription) {
return Response.json({ active: false, subscription: null });
}
const rzpSub = await razorpay.subscriptions.fetch(
subscription.razorpaySubscriptionId
);
const plan = await razorpay.plans.fetch(rzpSub.plan_id);
const payments = await db
.select()
.from(paymentsTable)
.where(eq(paymentsTable.subscriptionId, subscription.id))
.orderBy(desc(paymentsTable.createdAt))
.limit(5);
let pendingInvoices: any[] = [];
try {
const invoices = await razorpay.invoices.all({
subscription_id: subscription.razorpaySubscriptionId,
status: "issued",
});
pendingInvoices = invoices.items || [];
} catch {
}
return Response.json({
active: ["active", "authenticated"].includes(rzpSub.status),
subscription: {
planName: plan.item.name,
status: rzpSub.status,
nextBillingDate: rzpSub.current_end
? new Date(rzpSub.current_end * 1000).toISOString()
: null,
amountPaise: plan.item.amount,
currency: plan.item.currency,
cancelledAt: rzpSub.ended_at
? new Date(rzpSub.ended_at * 1000).toISOString()
: null,
},
recentPayments: payments,
pendingInvoices: pendingInvoices.map((inv: any) => ({
id: inv.id,
amountPaise: inv.amount,
status: inv.status,
shortUrl: inv.short_url,
issuedAt: inv.issued_at
? new Date(inv.issued_at * 1000).toISOString()
: null,
})),
});
} catch (error) {
console.error("Failed to fetch billing status:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
2. Payment History API Route
Paginated payment history with receipt/invoice links.
import { razorpay } from "@/lib/razorpay";
export async function GET(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = Math.min(parseInt(searchParams.get("limit") || "10", 10), 50);
const offset = (page - 1) * limit;
try {
const subscription = await getActiveSubscriptionByUserId(user.id);
if (!subscription) {
return Response.json({ payments: [], total: 0 });
}
const [payments, countResult] = await Promise.all([
db
.select()
.from(paymentsTable)
.where(eq(paymentsTable.userId, user.id))
.orderBy(desc(paymentsTable.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(paymentsTable)
.where(eq(paymentsTable.userId, user.id)),
]);
const total = countResult[0]?.count ?? 0;
const enrichedPayments = payments.map((payment) => ({
id: payment.id,
date: payment.createdAt,
amountPaise: payment.amountPaise,
currency: payment.currency || "INR",
status: payment.status,
method: payment.method,
invoiceUrl: payment.razorpayInvoiceId
? `/api/billing/invoice/${payment.razorpayInvoiceId}`
: null,
}));
return Response.json({
payments: enrichedPayments,
total,
page,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
console.error("Failed to fetch payment history:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
3. Cancel Subscription Flow
Two-step cancel: collect reason + offer save → then cancel at cycle end (not immediately) so the user keeps access until their paid period expires.
Step 1: Cancellation Reasons + Save Offer
Before actually cancelling, show a cancellation survey and a save offer. This is where you retain users.
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const { reason, feedback } = await request.json();
await db.insert(cancellationReasons).values({
userId: user.id,
reason,
feedback,
createdAt: new Date(),
converted: false,
});
const saveOffer = getSaveOffer(reason);
return Response.json({ saveOffer });
}
function getSaveOffer(reason: string) {
switch (reason) {
case "too_expensive":
return {
type: "discount",
message: "How about 30% off for the next 3 months?",
action: "apply_discount",
};
case "not_using":
return {
type: "pause",
message: "Want to pause your subscription for a month instead?",
action: "pause_subscription",
};
case "missing_feature":
return {
type: "feedback",
message: "We'd love to hear what you need. Our team will reach out within 24 hours.",
action: "notify_team",
};
default:
return null;
}
}
Step 2: Actually Cancel
import { razorpay } from "@/lib/razorpay";
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const { immediate } = await request.json().catch(() => ({ immediate: false }));
try {
const subscription = await getActiveSubscriptionByUserId(user.id);
if (!subscription) {
return Response.json({ error: "No active subscription" }, { status: 400 });
}
await razorpay.subscriptions.cancel(
subscription.razorpaySubscriptionId,
!immediate
);
const rzpSub = await razorpay.subscriptions.fetch(
subscription.razorpaySubscriptionId
);
await db
.update(subscriptions)
.set({
cancelledAt: new Date(),
status: immediate ? "cancelled" : "cancelling",
updatedAt: new Date(),
})
.where(eq(subscriptions.id, subscription.id));
const accessExpiresAt = immediate
? new Date().toISOString()
: rzpSub.current_end
? new Date(rzpSub.current_end * 1000).toISOString()
: null;
return Response.json({
cancelled: true,
immediate,
accessExpiresAt,
message: immediate
? "Your subscription has been cancelled immediately."
: accessExpiresAt
? `Your subscription has been cancelled. You'll continue to have access until ${new Date(rzpSub.current_end! * 1000).toLocaleDateString()}.`
: "Your subscription has been cancelled.",
});
} catch (error) {
console.error("Failed to cancel subscription:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
Pause Subscription (Alternative to Cancel)
Razorpay has a pause API for subscriptions. Use this for "not using right now" scenarios:
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
const subscription = await getActiveSubscriptionByUserId(user.id);
if (!subscription) {
return Response.json({ error: "No active subscription" }, { status: 400 });
}
await razorpay.subscriptions.pause(
subscription.razorpaySubscriptionId,
{ pause_initiated_by: "customer" }
);
await db
.update(subscriptions)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(subscriptions.id, subscription.id));
return Response.json({
paused: true,
message: "Your subscription is paused. You can resume anytime.",
});
} catch (error) {
console.error("Failed to pause subscription:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
const subscription = await getActiveSubscriptionByUserId(user.id);
if (!subscription || subscription.status !== "paused") {
return Response.json({ error: "No paused subscription" }, { status: 400 });
}
await razorpay.subscriptions.resume(
subscription.razorpaySubscriptionId,
{ resume_initiated_by: "customer" }
);
await db
.update(subscriptions)
.set({ status: "active", updatedAt: new Date() })
.where(eq(subscriptions.id, subscription.id));
return Response.json({
resumed: true,
message: "Your subscription is active again.",
});
} catch (error) {
console.error("Failed to resume subscription:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
4. Invoice / Receipt Download
Razorpay does NOT auto-generate invoices for subscription payments. You must create them via the Razorpay Invoice API (razorpay.invoices.create()) — see the webhook handler's createGstInvoice() function. Once created, each invoice has a short_url for a Razorpay-hosted invoice page. Store the razorpay_invoice_id and short_url in your DB when creating the invoice.
import { razorpay } from "@/lib/razorpay";
export async function GET(
request: Request,
{ params }: { params: { invoiceId: string } }
) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
const dbInvoice = await getInvoiceByRazorpayId(params.invoiceId);
if (dbInvoice && dbInvoice.userId !== user.id) {
return new Response("Not found", { status: 404 });
}
if (dbInvoice?.shortUrl) {
return Response.redirect(dbInvoice.shortUrl, 302);
}
const invoice = await razorpay.invoices.fetch(params.invoiceId);
const subscription = await getSubscriptionByRazorpayId(
invoice.subscription_id
);
if (!subscription || subscription.userId !== user.id) {
return new Response("Not found", { status: 404 });
}
if (invoice.short_url) {
return Response.redirect(invoice.short_url, 302);
}
return Response.json({
invoiceId: invoice.id,
amountPaise: invoice.amount,
currency: invoice.currency,
status: invoice.status,
issuedAt: invoice.issued_at
? new Date(invoice.issued_at * 1000).toISOString()
: null,
customerDetails: invoice.customer_details,
lineItems: invoice.line_items,
});
} catch (error) {
console.error("Failed to fetch invoice:", error);
return Response.json({ error: "Invoice not found" }, { status: 404 });
}
}
5. Update Payment Method
Razorpay does not support updating the card on an existing subscription. The pattern is: create a new subscription with the same plan, then cancel the old one after payment succeeds (deferred cancellation).
import { razorpay } from "@/lib/razorpay";
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
const current = await getActiveSubscriptionByUserId(user.id);
if (!current) {
return Response.json({ error: "No active subscription" }, { status: 400 });
}
const subscription = await razorpay.subscriptions.create({
plan_id: current.razorpayPlanId,
total_count: totalCountFor(current.planKey),
quantity: 1,
customer_notify: 1,
notes: {
userId: user.id,
planKey: current.planKey,
replacesSubscription: current.razorpaySubscriptionId,
reason: "payment_method_update",
},
});
return Response.json({
shortUrl: subscription.short_url,
subscriptionId: subscription.id,
message:
"You'll be redirected to complete payment with your new card. Your current subscription will transfer automatically.",
});
} catch (error) {
console.error("Failed to update payment method:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
6. Reactivation After Cancellation
Cancelled subscriptions cannot be reactivated in Razorpay. Create a fresh subscription with the same plan.
import { razorpay } from "@/lib/razorpay";
export async function POST(request: Request) {
const user = await getAuthenticatedUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const { planKey } = await request.json();
try {
const existing = await getActiveSubscriptionByUserId(user.id);
if (existing) {
return Response.json(
{ error: "You already have an active subscription" },
{ status: 409 }
);
}
const customer = await razorpay.customers.create({
name: user.name || "Customer",
email: user.email,
...(user.phone ? { contact: user.phone.replace(/[^\d+]/g, "") } : {}),
fail_existing: 0 as 0 | 1,
});
const planId = planIdFor(planKey);
const subscription = await razorpay.subscriptions.create({
plan_id: planId,
total_count: totalCountFor(planKey),
quantity: 1,
customer_notify: 1,
notes: {
userId: user.id,
planKey,
},
});
return Response.json({
shortUrl: subscription.short_url,
subscriptionId: subscription.id,
});
} catch (error) {
console.error("Failed to resubscribe:", error);
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
7. Client-Side Billing Page Component
"use client";
import { useState, useEffect } from "react";
interface BillingStatus {
active: boolean;
subscription: {
planName: string;
status: string;
nextBillingDate: string | null;
amountPaise: number;
currency: string;
cancelledAt: string | null;
} | null;
recentPayments: Payment[];
pendingInvoices: Invoice[];
}
interface Payment {
id: string;
date: string;
amountPaise: number;
currency: string;
status: string;
method: string;
invoiceUrl: string | null;
}
interface Invoice {
id: string;
amountPaise: number;
status: string;
shortUrl: string;
issuedAt: string | null;
}
export function BillingPortal() {
const [billing, setBilling] = useState<BillingStatus | null>(null);
const [loading, setLoading] = useState(true);
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
useEffect(() => {
fetchBillingStatus();
}, []);
const fetchBillingStatus = async () => {
try {
const res = await fetch("/api/billing/status");
const data = await res.json();
setBilling(data);
} catch {
console.error("Failed to load billing status");
} finally {
setLoading(false);
}
};
const handleCancel = async () => {
setCancelLoading(true);
try {
const res = await fetch("/api/billing/cancel", { method: "POST" });
const data = await res.json();
if (data.cancelled) {
alert(data.message);
setShowCancelConfirm(false);
fetchBillingStatus();
} else {
alert(data.error || "Failed to cancel");
}
} catch {
alert("Something went wrong");
} finally {
setCancelLoading(false);
}
};
const handleUpdatePaymentMethod = async () => {
const res = await fetch("/api/billing/update-payment-method", {
method: "POST",
});
const data = await res.json();
if (data.shortUrl) {
window.open(data.shortUrl, "_blank");
}
};
const handleResubscribe = async (planKey: string) => {
const res = await fetch("/api/billing/resubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planKey }),
});
const data = await res.json();
if (data.shortUrl) {
window.open(data.shortUrl, "_blank");
}
};
if (loading) return <div>Loading billing details...</div>;
if (!billing) return <div>Failed to load billing details.</div>;
const sub = billing.subscription;
const isCancelled = sub?.status === "cancelled" || sub?.cancelledAt;
return (
<div style={{ maxWidth: 700, margin: "0 auto" }}>
<h2>Billing & Subscription</h2>
{/* ── Current Plan ─────────────────────────────── */}
{sub ? (
<div style={{ padding: 16, border: "1px solid #e5e7eb", borderRadius: 8, marginBottom: 24 }}>
<h3>{sub.planName}</h3>
<p>
Status:{" "}
<strong>
{isCancelled ? "Cancelled" : sub.status}
</strong>
</p>
<p>
Amount: {sub.currency}{" "}
{(sub.amountPaise / 100).toFixed(2)} / cycle
</p>
{sub.nextBillingDate && !isCancelled && (
<p>
Next billing date:{" "}
{new Date(sub.nextBillingDate).toLocaleDateString()}
</p>
)}
{isCancelled && sub.nextBillingDate && (
<p style={{ color: "#b45309" }}>
Access until:{" "}
{new Date(sub.nextBillingDate).toLocaleDateString()}
</p>
)}
{/* Actions */}
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
{!isCancelled && (
<>
<button onClick={handleUpdatePaymentMethod}>
Update Payment Method
</button>
<button
onClick={() => setShowCancelConfirm(true)}
style={{ color: "#dc2626" }}
>
Cancel Subscription
</button>
</>
)}
{isCancelled && (
<button onClick={() => handleResubscribe(sub.planName)}>
Resubscribe
</button>
)}
</div>
</div>
) : (
<p>No active subscription.</p>
)}
{}
{showCancelConfirm && (
<div
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<div style={{ background: "white", padding: 24, borderRadius: 12, maxWidth: 400 }}>
<h3>Cancel Subscription?</h3>
<p>
Your subscription will remain active until the end of your current
billing period
{sub?.nextBillingDate && (
<strong>
{" "}({new Date(sub.nextBillingDate).toLocaleDateString()})
</strong>
)}
. After that, you will lose access.
</p>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={() => setShowCancelConfirm(false)}>
Keep Subscription
</button>
<button
onClick={handleCancel}
disabled={cancelLoading}
style={{ color: "#dc2626" }}
>
{cancelLoading ? "Cancelling..." : "Yes, Cancel"}
</button>
</div>
</div>
</div>
)}
{}
{billing.pendingInvoices.length > 0 && (
<div style={{ marginBottom: 24 }}>
<h3>Pending Invoices</h3>
{billing.pendingInvoices.map((inv) => (
<div
key={inv.id}
style={{
display: "flex", justifyContent: "space-between",
padding: 12, border: "1px solid #fde68a",
borderRadius: 8, marginBottom: 8, background: "#fffbeb",
}}
>
<span>
{inv.issuedAt
? new Date(inv.issuedAt).toLocaleDateString()
: "Pending"}{" "}
— INR {(inv.amountPaise / 100).toFixed(2)}
</span>
<a href={inv.shortUrl} target="_blank" rel="noopener">
Pay Now
</a>
</div>
))}
</div>
)}
{}
<h3>Payment History</h3>
{billing.recentPayments.length === 0 ? (
<p>No payments yet.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid #e5e7eb", textAlign: "left" }}>
<th style={{ padding: 8 }}>Date</th>
<th style={{ padding: 8 }}>Amount</th>
<th style={{ padding: 8 }}>Method</th>
<th style={{ padding: 8 }}>Status</th>
<th style={{ padding: 8 }}>Invoice</th>
</tr>
</thead>
<tbody>
{billing.recentPayments.map((payment) => (
<tr key={payment.id} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: 8 }}>
{new Date(payment.date).toLocaleDateString()}
</td>
<td style={{ padding: 8 }}>
{payment.currency} {(payment.amountPaise / 100).toFixed(2)}
</td>
<td style={{ padding: 8 }}>{payment.method || "—"}</td>
<td style={{ padding: 8 }}>{payment.status}</td>
<td style={{ padding: 8 }}>
{payment.invoiceUrl ? (
<a href={payment.invoiceUrl} target="_blank" rel="noopener">
Download
</a>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
Gotchas
current_end is Unix seconds: Razorpay returns timestamps as Unix seconds, not milliseconds. Multiply by 1000 before passing to new Date().
- Cancelled subscriptions stay active until period end: After calling
cancel(id, true), the subscription status may still show active on Razorpay's side until the period ends. Show the user their access expiry date clearly.
- Razorpay does NOT auto-generate invoices for subscription payments: You must create invoices yourself via the Razorpay Invoice API (
razorpay.invoices.create()) with proper GST line items. Store the razorpay_invoice_id and short_url in your DB. If an invoice wasn't created for a payment, show "No invoice available" instead of erroring.
- Rate limit your billing status endpoint: The
/api/billing/status route calls the Razorpay API on every request. Add caching (e.g., 60-second TTL) or read from your DB instead of hitting Razorpay directly.
- Cache plan details: Plan name, amount, and currency almost never change. Fetch once and cache, or store in your DB when the subscription is created.
cancel(id, true) not cancel(id, { at_cycle_end: true }): The second parameter to razorpay.subscriptions.cancel() is a boolean, not an options object. The SDK types may be misleading.
- Cancelled subscriptions cannot be reactivated: There is no "resume" API. You must create a brand new subscription and take the user through checkout again.
- Payment method update = new subscription: Razorpay does not support swapping cards on an existing subscription. Use the deferred cancellation pattern (create new, webhook cancels old). See the
plan-change skill for the webhook handler.
- Amount is always in paise: 100 paise = 1 INR. Display as
(amountPaise / 100).toFixed(2) everywhere.