Razorpay, GST Invoices, and Everything Foreign Tutorials Skip When You Build for India
You followed the Stripe tutorial. Clean checkout, webhook listener, the whole thing working on localhost in an afternoon. Then your first Indian customer...
Garvit Sharma
24 June 2026 · 7 MIN READ
Razorpay, GST Invoices, and Everything Foreign Tutorials Skip When You Build for India
webight.comOn this page
- Payments: Stripe is the wrong default
- Every payment needs a GST invoice, not a receipt
- Your user is on a phone, holding it in one hand
- UPI is the main flow, treat it that way
- WhatsApp is your support channel, not email
- Data residency is heading toward "keep it in India"
- ISPs can pull the rug, so own your domain edge
- The pattern under all of this
You followed the Stripe tutorial. Clean checkout, webhook listener, the whole thing working on localhost in an afternoon. Then your first Indian customer tries to pay and the card just sits there spinning, because half the country pays by scanning a QR code with their thumb, not typing a 16-digit number.
That gap is the whole post. Every US tutorial assumes a US user, a US payment rail, and US laws. You copy the code, it runs, and then it quietly fails the moment a real Indian opens it on a 4-year-old Android phone on Jio. I build for Indian clients out of Noida, and I've hit every one of these walls. Here is the list, with what breaks and the India-correct fix for each.
Payments: Stripe is the wrong default
The tutorial says Stripe. Stripe doesn't onboard most new Indian businesses for domestic payments. So you build the entire flow, get to KYC, and the rail you depended on isn't available to your client. That's hours gone.
Use Razorpay. It supports UPI, cards, netbanking, and wallets in one integration, and it's built for Indian compliance from day one.
Here is a real server-side order creation. Next.js App Router, TypeScript.
// src/app/api/razorpay/order/route.ts
import Razorpay from "razorpay";
import { 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: Request) {
const { amountInPaise } = await req.json();
const order = await razorpay.orders.create({
amount: amountInPaise, // 799900 for INR 7,999
currency: "INR",
receipt: `rcpt_${Date.now()}`,
notes: { product: "landing-page" },
});
return NextResponse.json({ orderId: order.id, amount: order.amount });
}
Note the amount is in paise. INR 7,999 is 799900, not 7999. People get this wrong and charge customers 100x. Always store and pass paise.
And never trust the client to tell you payment succeeded. Verify the signature on your server after Razorpay calls back.
// src/app/api/razorpay/verify/route.ts
import crypto from "crypto";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { orderId, paymentId, signature } = await req.json();
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${orderId}|${paymentId}`)
.digest("hex");
if (expected !== signature) {
return NextResponse.json({ ok: false }, { status: 400 });
}
// mark order paid in Postgres here
return NextResponse.json({ ok: true });
}
Every payment needs a GST invoice, not a receipt
The Stripe tutorial sends a thank-you email and calls it done. In India, if your client is GST-registered, every single payment has to produce a tax invoice with specific fields. A "receipt" is not enough. The taxman wants the seller GSTIN, the customer GSTIN if they have one, the HSN or SAC code, and the tax split.
The split is the part foreign code never has. If buyer and seller are in the same state, the tax is CGST plus SGST, half each. If they're in different states, it's a single IGST. Same total, different breakup, and you have to print the right one.
Here is the logic. Service sold at 18% GST.
// src/lib/gst.ts
type Line = { sacCode: string; description: string; amount: number };
export function buildInvoice(opts: {
sellerState: string; // e.g. "DL"
buyerState: string; // e.g. "MH"
sellerGstin: string;
buyerGstin?: string;
line: Line;
gstRate: number; // 0.18
}) {
const { line, gstRate, sellerState, buyerState } = opts;
const taxable = line.amount;
const totalTax = +(taxable * gstRate).toFixed(2);
const sameState = sellerState === buyerState;
const tax = sameState
? { cgst: +(totalTax / 2).toFixed(2), sgst: +(totalTax / 2).toFixed(2), igst: 0 }
: { cgst: 0, sgst: 0, igst: totalTax };
return {
sellerGstin: opts.sellerGstin,
buyerGstin: opts.buyerGstin ?? "UNREGISTERED",
sacCode: line.sacCode, // 998314 for IT design/dev services
taxableValue: taxable,
...tax,
invoiceTotal: +(taxable + totalTax).toFixed(2),
placeOfSupply: buyerState,
};
}
One thing that bites people: the price your customer sees should usually be the GST-inclusive number. An Indian buyer expects INR 7,999 to be the final amount, then you back-calculate the taxable value from it. Foreign checkouts add tax on top at the end. Do that here and customers feel cheated at the last screen. We run this past Utsav, who is a CA, before anything goes live, because getting the place-of-supply rule wrong is a compliance problem, not a bug.
Your user is on a phone, holding it in one hand
US tutorials test on a desktop with a fast connection. The median Indian user is on a mid-range Android phone, on mobile data, one-handed, maybe with the screen at half brightness in sunlight. If your tap targets are tiny and your checkout needs a keyboard, you've lost them.
Design phone-first for real. Big tap targets, minimum 48px. Inputs that fire the right mobile keyboard. The number pad should appear for an amount field, not the full QWERTY.
<input
type="tel"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code" // OTP autofill from SMS on Android
className="h-12 w-full rounded-xl px-4 text-lg"
/>
That one-time-code line matters more than it looks. OTP is everywhere in Indian flows, and Android can autofill it straight from the SMS if you set this. One less thing for a thumb to do.
UPI is the main flow, treat it that way
Card-first checkout is a foreign habit. UPI is how India pays. In a recent build, around 81% of completed payments came through UPI, not cards. If your checkout buries UPI under a "more options" link, you're hiding the rail most people actually use.
Put UPI first in the Razorpay checkout config and let the rest follow.
const options = {
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID!,
amount: order.amount,
currency: "INR",
order_id: order.orderId,
config: {
display: {
sequence: ["block.upi", "block.others"],
preferences: { show_default_blocks: true },
},
},
handler: (res: any) => verifyPayment(res),
};
On desktop, show the UPI QR. On mobile, the intent flow opens GPay or PhonePe directly. Two different paths, same rail, and you have to support both because your traffic is split across both.
WhatsApp is your support channel, not email
The foreign default is an email address in the footer and a Zendesk widget. Indian users do not email support. They message on WhatsApp, and they expect a reply there. We built a WhatsApp booking bot for a boutique hotel and it pulled 600+ direct bookings without a single rupee of ads, purely because people were already in that app and never had to leave it.
At minimum, give a click-to-chat link with the message prefilled, so the user lands in a conversation instead of a blank inbox.
<a
href={`https://wa.me/919999999999?text=${encodeURIComponent(
"Hi, I paid for the landing page plan and want to share my brand assets."
)}`}
>
Chat with us on WhatsApp
</a>
Prefill the context. A user who has to explain who they are will often just not message at all.
Data residency is heading toward "keep it in India"
US tutorials spin up a database in us-east-1 and never think about it again. India's DPDP Act is pushing the direction of where personal data can sit and how it moves out of the country. The safe call now is to keep Indian user data in an Indian region and know exactly which third parties touch it.
Practically: pick the closest region when you provision. Lower latency for your users and you're aligned with where the law is going. Don't dump everything in Virginia because the tutorial did.
ISPs can pull the rug, so own your domain edge
This is the one that almost no foreign tutorial will ever mention. In March 2026, Jio DNS-blocked *.supabase.co across India. Apps that talked directly to the Supabase domain just stopped working for a huge chunk of users, with nothing wrong in the code at all.
I had a production app on it: 46 tables, 27 edge functions, Google OAuth, Razorpay. I moved it behind a Cloudflare Worker proxy on my own domain in about 2 hours, cost 0 rupees. The client's users never saw a thing. The one trap was Google OAuth, because the browser callback was hitting supabase.co directly, so I switched it to the ID-token flow to route through my own domain.
The lesson is simple. Don't let a third-party domain be a single point of failure your users hit directly. Front it with your own domain so that when an ISP does something stupid, you change one DNS record instead of rewriting an app under fire.
// Cloudflare Worker: proxy your-domain.com/api/* to the upstream
export default {
async fetch(req) {
const url = new URL(req.url);
const upstream = `https://xxxx.supabase.co${url.pathname}${url.search}`;
return fetch(upstream, {
method: req.method,
headers: req.headers,
body: req.method === "GET" ? undefined : req.body,
});
},
};
The pattern under all of this
None of these are exotic. They're just the default assumptions in foreign tutorials being wrong for the country you're shipping to. The tutorial encodes one place, one set of laws, one kind of user. You copy it and inherit all of that without noticing, until a real user breaks it.
The fix is the same every time: learn the domain before you build, not after. Before I touched a payment flow, I sat with a CA so the GST split was right the first time. That hour of domain knowledge saved a week of compliance cleanup.
So next time you open a US tutorial, read it for the structure and throw away every assumption baked into it. Build for the user who's actually going to tap your screen, not the one in the screenshot.
Hit me on WhatsApp or drop a reply if you want the full GST invoice template, I'll send it over.
We build fast websites and automation for small businesses.
The two people who build it are the two you talk to, and every price is on the page.

Garvit Sharma
Full-stack developer and co-founder of Webight, a two-person web and AI studio in India. He writes these from real client work. More about us.
Keep reading
// RELATED'Near Me' Searches in Noida and Gurugram Go to Sites With One Specific Page. Here's the Page.
webight.com'Near Me' Searches in Noida and Gurugram Go to Sites With One Specific Page. Here's the Page.
7 MIN READ
Hindi Search Queries Are Exploding. Almost No Business Has a Page That Answers Them.
webight.comHindi Search Queries Are Exploding. Almost No Business Has a Page That Answers Them.
7 MIN READ
Indian SMB Sites Load in 9 Seconds on Jio. Your Customers Are on Jio.
webight.comIndian SMB Sites Load in 9 Seconds on Jio. Your Customers Are on Jio.
8 MIN READ