DEV Community

Archit Mittal
Archit Mittal

Posted on • Originally published at architmittal.com

Build a UPI QR Code Generator with Logo in 46 Lines of Python

Every time I pay a vendor in India, I'm reminded that UPI is just a URL standard. upi://pay?pa=merchant@bank&pn=Name&am=199.00 is all your phone is reading when it scans that QR taped to the cashier's counter. Which means you don't need PhonePe Business, a payment gateway, or a ₹2999/month "QR builder SaaS" to generate one for yourself.

You need 46 lines of Python.

I built this last week because a friend running a small Chennai cafe was paying a vendor ₹500/month for static-QR stickers with her UPI on them. Forty-six lines of code later, she's printing her own — with her cafe logo in the middle, pre-filled amounts for combo plates, and a transaction reference embedded so reconciliation in Tally takes 30 seconds instead of 30 minutes.

Here's the whole thing.

The full code

import qrcode
from PIL import Image
from urllib.parse import quote
import argparse

def build_upi_url(vpa, name, amount=None, note=None, ref=None):
    params = {"pa": vpa, "pn": quote(name), "cu": "INR"}
    if amount is not None:
        params["am"] = f"{amount:.2f}"
    if note:
        params["tn"] = quote(note)
    if ref:
        params["tr"] = ref
    return "upi://pay?" + "&".join(f"{k}={v}" for k, v in params.items())

def make_qr(data, logo_path, out_path):
    qr = qrcode.QRCode(
        error_correction=qrcode.constants.ERROR_CORRECT_H,
        box_size=12,
        border=2,
    )
    qr.add_data(data)
    qr.make(fit=True)
    img = qr.make_image(fill_color="#0F172A", back_color="white").convert("RGB")
    if logo_path:
        logo = Image.open(logo_path).convert("RGBA")
        size = img.size[0] // 5
        logo.thumbnail((size, size))
        pos = ((img.size[0] - logo.size[0]) // 2,
               (img.size[1] - logo.size[1]) // 2)
        img.paste(logo, pos, mask=logo)
    img.save(out_path)

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--vpa", required=True)
    p.add_argument("--name", required=True)
    p.add_argument("--amount", type=float)
    p.add_argument("--note")
    p.add_argument("--ref")
    p.add_argument("--logo")
    p.add_argument("--out", default="upi.png")
    a = p.parse_args()
    url = build_upi_url(a.vpa, a.name, a.amount, a.note, a.ref)
    make_qr(url, a.logo, a.out)
    print(f"Saved {a.out}\nURL: {url}")
Enter fullscreen mode Exit fullscreen mode

That's it. Forty-six lines, two dependencies (qrcode and Pillow), zero API keys, runs on a Raspberry Pi.

Install and run

pip install "qrcode[pil]" Pillow
python upi_qr.py --vpa cafe@okhdfcbank --name "Mocha Lane" \
                 --amount 249 --note "Cold Brew Combo" \
                 --ref ORD-20260601-007 --logo logo.png --out combo.png
Enter fullscreen mode Exit fullscreen mode

You get a combo.png with your logo dead-center and a UPI deeplink that, when scanned, opens any UPI app with the payee, amount, and note pre-filled. The customer just hits "Pay."

Why it actually works in production

There are three details that matter and that ₹2999/month SaaS tools love to hide behind a paywall:

1. Error correction level H. This is the H in ERROR_CORRECT_H — it tells the QR code to encode ~30% redundant data. That redundancy is what lets you cover the center fifth of the code with a logo and still have it scan reliably from a wobbly hand at a counter. Drop it to M and your logo starts breaking scans on cheap Android phones.

2. cu=INR is required. I learned this the embarrassing way. PhonePe will happily scan a QR without the currency tag, populate the payee, and then throw "Unsupported currency" when the user taps pay. NPCI's spec marks cu as optional but every production app I've tested treats it as mandatory. Always include it.

3. URL-encode the payee name and note. If your café is called "Café Bombay" or your note is "Order #7 — table 2," the unencoded ampersands and special characters will silently truncate the rest of the parameters. urllib.parse.quote is two extra characters and saves you a debugging afternoon.

What the parameters mean

The UPI URL spec (NPCI's deeplink standard) accepts these fields:

  • pa — payee address, i.e. your VPA / UPI handle (yourname@okhdfcbank)
  • pn — payee name shown to the customer in their UPI app
  • am — amount in ₹, two decimal places
  • cu — currency, always INR
  • tn — transaction note (shown as the description in the customer's app)
  • tr — transaction reference / order ID — this is the magic field for reconciliation
  • mc — merchant category code (optional, for registered merchants)

The tr field is the one nobody talks about. If you set --ref ORD-20260601-007 and the customer pays, that exact reference shows up in your UPI app's transaction history. Match it against your order DB, mark the order paid, done. No more "did Rohit's ₹249 come through?" at 9pm.

Three things you can build on this

Once you have the building block, the surface area opens up fast.

Per-order QRs. Wrap build_upi_url in a Flask endpoint, pass the order ID as tr, render the PNG inline. Now every Shopify/Razorpay-free checkout page on your site has a "Pay via UPI" QR with the exact amount and a reference your backend can match. I built this for a client doing handmade soap on Instagram — saved her the 2% Razorpay cut on every ₹400 order. ₹8/order × 600 orders/month = ₹4,800/month, recovered.

Dynamic table QRs for restaurants. Same code, loop over your tables, generate Table_01.png through Table_24.png, each with the table number baked into tn. Customer scans, pays, your staff sees "Table 14 paid ₹890" in the UPI notification — no calling them over to confirm.

Donation pages with suggested amounts. Generate four QRs at ₹100/₹500/₹1000/₹5000 with the same VPA and a campaign tag in tr. Embed all four on a webpage. NGOs love this because UPI doesn't take the 2-3% that Razorpay/Instamojo take on donations.

Where to take it next

The 46-line version is deliberately bare. A few honest extensions I'd add before deploying it for anything customer-facing:

  • Bulk generation from a CSV — feed it orders.csv and dump 500 PNGs into a folder. Fifteen lines.
  • PDF sheets for printing — paste 12 QRs onto an A4 grid with reportlab. Twenty lines.
  • Quiet-zone validation — a logo at exactly 20% of QR area is the safe ceiling. Push past 25% and you start losing scans on older phones.
  • Print on dark backgrounds — flip fill_color and back_color, but always test with the actual UPI app you expect customers to use. GPay's scanner is more forgiving than PhonePe's.

The whole project is one file. Drop it into a cron job and you've got a self-hosted, zero-cost alternative to half the "UPI for Business" SaaS market.


That's the pattern I keep coming back to: most "SaaS automation" is one or two carefully-chosen Python files away from being a thing you own.

Follow me on Twitter @automate_archit for daily AI automation tips — I post one of these every weekday, all India-relevant, all under 100 lines.

Top comments (1)

Collapse
 
kushal1o1 profile image
KUSHAL BARAL

good bro :)