DEV Community

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

Posted on

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.

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
Enter fullscreen mode Exit fullscreen mode

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}`;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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()}`,
  );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo ShivamS136 / buy-me-a-chai

Zero commission. Zero platform. Zero signup. Your donation page, your UPI, your host.

☕ buy-me-a-chai

CI Deploy to Pages License: MIT Node Commission

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.

buy-me-a-chai — donation page screenshot

🔗 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)