What I learned building a payments page with no payment processor - A free, self-hosted, zero commission donation platform
DEV Community

What I learned building a payments page with no payment processor - A free, self-hosted, zero commission donation platform

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 . 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+202E and 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 encodeURIComponent throws 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://pay intents). 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)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.