I just shipped buy-me-a-chai: an open-source "Buy Me a Coffee"-style donation page for Indian creators that charges 0% commission, forever. Not as a promotion — as architecture. There is no backend, no database, no accounts, and no payment processor anywhere in the project. It's a static site template: fork it, edit one YAML file, deploy free on GitHub Pages, and donors pay your bank account directly.
Building a payments page with no payment infrastructure sounds like a contradiction. It's not — but only because of one deliberate trade-off, and getting there taught me more about URL encoding, mobile browser policy, and Unicode than any project I've done. Here's the tour.
- Template Repo: github.com/ShivamS136/buy-me-a-chai
- Demo Page: shivams136.github.io/buy-me-a-chai
- My Chai Page: shivams136.github.io/chai-for-me
UPI in 90 seconds (for readers outside India)
UPI (Unified Payments Interface) is India's instant payment system — public infrastructure that moves money bank-to-bank in seconds, for free, between any two people. It processes more transactions than any comparable system on earth, and the street vendor selling you chai accepts it via a paper QR code taped to their cart.
That QR code encodes a standardized URI:
upi://pay?pa=creator@bank&pn=Creator%20Name&am=150.00&cu=INR&tn=Thanks%20for%20the%20blog
pa is the payment address (a "VPA" like shivam@okaxis), am the amount, tn a note. Any UPI app — Google Pay, PhonePe, Paytm, BHIM — opens this URI with everything pre-filled. The payer confirms with their PIN, and money moves directly between bank accounts.
Read that again: a plain-text URI is a complete payment request. No API keys, no SDK, no server. My entire "payments integration" is string concatenation.
The trade-off that makes 0% possible
So why do donation platforms in India still take ~5% of a ₹20 chai?
Because UPI person-to-person payments have no callback API. Nothing tells your page "payment completed." To confirm payments you must become a payment aggregator — a regulated intermediary the money routes through — and that's where commissions, KYC, and platform risk come from.
Every platform treats the missing confirmation as the gap they exist to fill. I went the other way: accept that the page never knows whether anyone paid. In exchange:
- No money ever touches the project → nothing to take a cut of
- No server → nothing to host, nothing to breach, ₹0/month
- No accounts → nothing to sign up for, no donor data at all
The constraint isn't a limitation of the product. The constraint is the product. (It also imposes an honesty rule that's now a hard rule in the repo: the page must never show "Thank you for your donation!" — we can't know. The most it says is: "QR scanned? Payment happens in your UPI app.")
Landmine #1: URLSearchParams silently corrupts UPI links
The obvious way to build that URI is the standard library:
const params = new URLSearchParams({ pa: vpa, pn: name, tn: note });
`upi://pay?${params}`;
Don't. URLSearchParams implements the WHATWG form-urlencoded serializer, which encodes a space as +:
new URLSearchParams({ tn: 'thanks for the blog' }).toString();
// "tn=thanks+for+the+blog"
encodeURIComponent('thanks for the blog');
// "thanks%20for%20the%20blog"
UPI apps decode the query string with plain percent-decoders. They do not treat + as a space. So the donor's note — and worse, the creator's name on the payment screen — shows up as thanks+for+the+blog. It looks broken at the exact moment someone is deciding whether to send you money.
The fix is encodeURIComponent, plus escaping the five characters it leaves alone (!'()*) so the payload is strictly RFC 3986 unreserved:
export const encodeUpiComponent = (value: string): string =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
One more subtlety: encode pn and tn only. The VPA in pa is emitted verbatim — percent-encoding its @ breaks apps with naive parsers that never decode the field.
Landmine #2: the deeplink that fails silently
On mobile, the dream UX is a "Pay now" button that's just <a href="upi://pay?...">. Tap, UPI app opens pre-filled, done.
Here's what the docs won't tell you: Google Pay and PhonePe restrict upi:// intents launched from mobile browsers to personal (non-merchant) VPAs. It's an anti-fraud policy, it's undocumented, and it fails silently — sometimes nothing happens, sometimes the app opens and ignores the amount, sometimes you get a cryptic "exceeds limit for this merchant" for someone who isn't a merchant.
You cannot fix this from a web page. What you can do is stop pretending. The design that survived:
- The QR code and Copy UPI ID button are the primary paths — they work everywhere, because scanning from inside a UPI app isn't subject to browser-intent policy.
- The deeplink still exists, but demoted: mobile-only, visually the quietest element, labeled honestly ("works on most UPI apps").
- Nothing is ever hidden behind the deeplink — that's a hard rule in the repo, because a fallback you reach after a silent failure is a fallback nobody finds.
There's a related trap in the URI itself: never add merchant params (mc, tr) to a P2P intent — they can trigger merchant-verification failures against unregistered VPAs. And amounts are whole rupees only: several apps drop or round the paise component of am on P2P intents, and "₹1.50 on the page, ₹1 in the app" is a trust break I have no confirmation channel to even detect.
The scariest bug class: a typo'd payment address
A one-character typo in a UPI ID doesn't error. It sends every donation to a stranger, unrecoverably. For a template that thousands of people configure by hand, this is the catastrophic failure mode — so it's defended twice:
1. An invalid VPA fails the build, not the donor. The config file is parsed with Zod inside a Vite plugin, and a throw during the build is the feature:
✖ chai.config.yaml invalid:
creator.vpa → Invalid UPI ID "shivam okaxis". Expected format
like name@bank. Double-check in your UPI app → profile.
chai.basePrice → Expected integer ≥ 1, got 0
Every error reports at once (not fail-fast — a config with two problems should say both), unknown keys get Levenshtein-powered "did you mean creator?" suggestions, and because validation runs at build time, Zod never ships to the browser — the page receives a plain, already-validated object.
One rule I had to stop myself from adding: no case normalization. VPAs are case-insensitive, but silently rewriting what the creator typed is exactly the class of mutation this bug lives in. Validate loudly; never "fix" quietly.
2. The ₹1 self-test. The setup guide's final step is mandatory: scan your own live QR and send yourself ₹1 before sharing the page. Software validation proves the format; only a real payment proves the destination.
Bonus round: the note field is a Unicode minefield
The donor's message rides along as tn. Sanitizing 60 characters of free text turned out to be its own adventure:
-
Bidi override characters (
U+202Eand friends) inside a note can visually reverse the text shown in the payer's app — a spoofing vector, stripped. - But you can't blanket-strip "invisible" characters: ZWNJ is semantically required in Devanagari conjuncts, and ZWJ is how family and profession emoji compose. Strip those and you've corrupted every Hindi note. 🙃
- Truncation slices code points, not UTF-16 units — cutting a surrogate pair in half leaves a lone surrogate, and
encodeURIComponentthrows on one. Since the URI rebuilds on every keystroke, that's a crash in a React render path, triggered by typing an emoji in the wrong spot.
What shipped
A page that renders a live QR (client-side, regenerated on every amount/note change), a one-tap Copy UPI ID, and an honestly-labeled deeplink — all pointing at a UPI ID that provably parsed, on a site with no backend to pay for or trust. Vite + React + TypeScript + Tailwind, MIT-licensed, ~100% branch coverage on the URI builder because string concatenation is the entire payment stack.
ShivamS136
/
buy-me-a-chai
Zero commission. Zero platform. Zero signup. Your donation page, your UPI, your host.
☕ buy-me-a-chai
Zero commission. Zero platform. Zero signup. Your donation page, your UPI, your host.
A self-hosted "Buy Me a Chai" page for Indian creators. Fork it, edit one config file, deploy free on GitHub Pages or Vercel. Donors pay you directly over UPI — scan a QR, copy your UPI ID, or one-tap into their UPI app. No middleman ever touches the money, so no one can take a cut.
Why not Buy Me a Coffee / buymeachai.in / chai4.me? They sit between you and your supporters — commissions, bugs, privacy questions, platform risk. UPI P2P is already free and instant. This project just gives it a beautiful, embeddable face that you own.
🔗 Live demo — this repo, deployed as-is
Features
- 🪙 True 0% fees — plain UPI P2P (
upi://payintents). We can't take a cut of what we never touch. - ⚡ Live in ~15 min —…
One ask. Per-app upi:// deeplink behavior (GPay vs PhonePe vs Paytm vs BHIM, Android vs iOS) is the worst-documented thing on the Indian internet, and I'm building a community-sourced compatibility matrix in the repo's COMPAT.md. If you have a UPI app and 30 seconds, the live demo plus an issue report would genuinely help.
And if this saved you a platform commission — you know exactly what kind of page to thank me on. ☕

Top comments (0)