Great question. You can validate “payment QR codes” at two levels:
- Decode & structural checks (what you can do locally in code)
- Live verification (needs a PSP/gateway API; optional but best-practice)
Below is a practical, copy-pasteable approach you can drop into your stack (PHP/Laravel, plus quick Node/Python snippets).
1) What kinds of payment QR payloads you’ll see
- UPI intent URI (most common; including Paytm VPAs)
Example:upi://pay?pa=merchant@paytm&pn=Acme+Store&am=123.45&cu=INR&tn=Order+123
- EMVCo / BharatQR TLV string (starts with digits)
Example:000201010211...5303561540...6304ABCD
(ends with CRC “63 04 XXXX”) - Wallet/app-specific deep links
- Paytm:
upi://pay?...
orpaytmmp://...
or short URL likehttps://paytm.me/...
(Most Paytm merchant QRs are actually UPI with a Paytm VPA such as...@paytm
.)
- Paytm:
2) Flow to implement
A. Decode the QR image → get the raw text
- PHP:
khanamiryan/qrcode-detector-decoder
(ZXing port) - Node:
@zxing/library
,jsqr
- Python:
pyzbar
oropencv
+zbarlight
B. Decide the type & validate structurally
- If it starts with
upi://pay
→ parse as URI, validate required params. - If it looks like TLV (digits, starts with “00 02 01 02 11 …” etc.) → parse TLV and verify CRC16-CCITT in tag
63
. - If it’s app link (
paytmmp://
orhttps://paytm.me/...
) → allowlist the scheme/domain and (optionally) resolve server-side to ensure it’s a valid, reachable link owned by Paytm.
C. (Optional but recommended) Live verification
- You cannot guarantee a VPA exists or is active from the QR alone.
- Use a PSP/gateway that exposes VPA verification (aka “UPI PSP lookup”) APIs:
- Razorpay, Cashfree, Paytm for Business, PhonePe, Juspay, etc.
- Typically you send
pa=merchant@bank
to an endpoint → they reply if it’s valid and sometimes return the account holder name.
3) Validation rules (UPI intent)
For upi://pay
links:
- Scheme must be
upi
, host must bepay
- Required:
pa
(VPA):^[a-zA-Z0-9.\-_]{2,}@[a-zA-Z0-9.\-_]{2,}$
cu
(currency): usuallyINR
- Common/optional:
pn
(payee name),am
(amount),tn
(note),tr
(txn ref),tid
,mc
(merchant code),orgid
,sign
(signed intents) - If you require fixed amount QRs, ensure
am
is present and numeric (^\d+(\.\d{1,2})?$
). - Reject unknown params if you want a strict policy, or allow a whitelist (
pa,pn,am,tn,tr,tid,mc,cu,orgid,sign,mode
).
4) Validation rules (EMVCo/BharatQR TLV)
- TLV format:
<TAG><LEN><VALUE>
repeated, all numeric. - CRC is tag
63
with length04
. Compute CRC16-CCITT (0x1021, init 0xFFFF) over the bytes up to but not including the CRC value, then compare with the provided 4-hex checksum. - Presence of specific tags depends on the profile (UPI merchant, etc.). For basic acceptance, verifying a correct TLV structure + CRC already weeds out malformed codes.
5) PHP (Laravel-friendly) samples
A) Decode the QR
composer require khanamiryan/qrcode-detector-decoder
Code language: JavaScript (javascript)
use Zxing\QrReader;
function decodeQrImage(string $path): ?string {
$qrcode = new QrReader($path); // supports JPG/PNG, etc.
$text = $qrcode->text();
return $text !== '' ? $text : null;
}
Code language: PHP (php)
B) Validate UPI intent URI
function isValidUpiUri(string $s): bool {
// quick precheck
if (!str_starts_with($s, 'upi://pay')) return false;
$parts = parse_url($s);
if (($parts['scheme'] ?? '') !== 'upi') return false;
if (($parts['host'] ?? '') !== 'pay') return false;
parse_str($parts['query'] ?? '', $q);
// Required fields
if (empty($q['pa']) || empty($q['cu'])) return false;
// VPA pattern (basic)
if (!preg_match('/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$/', $q['pa'])) return false;
// Currency
if (strtoupper($q['cu']) !== 'INR') return false;
// Amount (if present)
if (isset($q['am']) && !preg_match('/^\d+(\.\d{1,2})?$/', $q['am'])) return false;
// Optional: enforce allowlist of params
$allowed = ['pa','pn','am','tn','tr','tid','mc','cu','orgid','sign','mode'];
foreach (array_keys($q) as $k) {
if (!in_array($k, $allowed, true)) return false; // tighten as you prefer
}
return true;
}
Code language: PHP (php)
C) Detect & validate EMVCo/BharatQR TLV (CRC check)
function looksLikeTlv(string $s): bool {
return ctype_digit($s) && strlen($s) >= 8; // rough heuristic
}
function parseTlv(string $s): array {
$i = 0; $fields = [];
while ($i + 4 <= strlen($s)) {
$tag = substr($s, $i, 2); $i += 2;
$len = intval(substr($s, $i, 2)); $i += 2;
$val = substr($s, $i, $len); $i += $len;
$fields[$tag] = $val;
if ($i > strlen($s)) break;
}
return $fields;
}
// CRC16-CCITT (0x1021), init 0xFFFF, no xorout
function crc16_ccitt(string $data): string {
$crc = 0xFFFF;
$bytes = unpack('C*', $data);
foreach ($bytes as $b) {
$crc ^= ($b << 8);
for ($i=0; $i<8; $i++) {
$crc = ($crc & 0x8000) ? (($crc << 1) ^ 0x1021) : ($crc << 1);
$crc &= 0xFFFF;
}
}
return strtoupper(str_pad(dechex($crc), 4, '0', STR_PAD_LEFT));
}
function isValidEmvcoWithCrc(string $s): bool {
if (!looksLikeTlv($s)) return false;
// Find CRC tag "63"
$pos = strpos($s, '63');
if ($pos === false) return false;
// The CRC field is "63 04 XXXX"
$crcFieldStart = $pos;
$dataForCrc = substr($s, 0, $crcFieldStart + 4); // include "63" + "04", exclude the 4 hex CRC value
$providedCrc = substr($s, $crcFieldStart + 4, 4);
// Compute CRC over the raw bytes, which here are ASCII digits; this simplified check is
// acceptable for gating malformed QRs. For production, convert TLV to actual bytes.
$calc = crc16_ccitt($dataForCrc);
return strtoupper($providedCrc) === $calc;
}
Code language: PHP (php)
D) Putting it together
function validatePaymentQr(string $imagePath): array {
$raw = decodeQrImage($imagePath);
if ($raw === null) {
return ['ok' => false, 'type' => null, 'reason' => 'QR not readable'];
}
// UPI intent?
if (isValidUpiUri($raw)) {
return ['ok' => true, 'type' => 'upi-intent', 'payload' => $raw];
}
// EMVCo TLV with valid CRC?
if (looksLikeTlv($raw) && isValidEmvcoWithCrc($raw)) {
return ['ok' => true, 'type' => 'emvco-tlv', 'payload' => $raw];
}
// Paytm deep links (allowlist)
if (str_starts_with($raw, 'paytmmp://') || str_starts_with($raw, 'https://paytm.me/')) {
return ['ok' => true, 'type' => 'paytm-link', 'payload' => $raw];
}
return ['ok' => false, 'type' => null, 'reason' => 'Unknown or malformed payment QR'];
}
Code language: PHP (php)
6) Node & Python quickies
Node (decode + basic UPI check)
npm i @zxing/library
Code language: CSS (css)
const { BrowserQRCodeReader } = require('@zxing/library');
function isValidUpiUri(s) {
try {
const u = new URL(s);
if (u.protocol !== 'upi:' || u.hostname !== 'pay') return false;
const pa = u.searchParams.get('pa');
const cu = u.searchParams.get('cu');
if (!pa || !/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$/.test(pa)) return false;
if ((cu || '').toUpperCase() !== 'INR') return false;
const am = u.searchParams.get('am');
if (am && !/^\d+(\.\d{1,2})?$/.test(am)) return false;
return true;
} catch { return false; }
}
Code language: JavaScript (javascript)
Python (pyzbar)
pip install pyzbar pillow
from pyzbar.pyzbar import decode
from PIL import Image
from urllib.parse import urlparse, parse_qs
def is_valid_upi_uri(s: str) -> bool:
try:
u = urlparse(s)
if u.scheme != 'upi' or u.netloc != 'pay': return False
q = {k:v[0] for k,v in parse_qs(u.query).items()}
pa = q.get('pa'); cu = (q.get('cu') or '').upper()
if not pa or not cu: return False
import re
if not re.match(r'^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$', pa): return False
if cu != 'INR': return False
am = q.get('am')
if am and not re.match(r'^\d+(\.\d{1,2})?$', am): return False
return True
except:
return False
def decode_qr(path: str) -> str | None:
d = decode(Image.open(path))
return d[0].data.decode() if d else None
Code language: JavaScript (javascript)
7) Security & product tips
- Don’t auto-trust a decoded string just because it parses. Enforce strict allowlists (schemes, hosts, param names).
- Sanitize & log the original payload for audit/debug.
- Optionally: if you only accept merchant (fixed-amount) QRs, reject those missing
am
. - Live verification: integrate a PSP’s VPA validation API to confirm the VPA exists and is active before saving the QR to your database.
- Rate-limit and virus-scan uploads; store images in a safe bucket; validate mime & dimensions.
- UX: after upload, show the decoded details (VPA, name, amount) back to the user for confirmation.
If you want something that is widely used in production and proven to work across banks, UPI apps, and wallets like Paytm, then the industry practice is:
✅ Most Widely Accepted Implementation
- UPI intent URI validation + (optionally) EMVCo TLV validation + VPA verification via PSP APIs.
- Almost all real-world QR codes in India today (Paytm, PhonePe, GPay, BHIM, BharatPe, banks) follow the NPCI UPI QR Standard which is based on EMVCo QR but typically presented as a
upi://pay?...
intent. - So, in practice:
- Decode the QR → you’ll almost always get
upi://pay?...
- Check required fields (
pa
,cu
) + format (VPA regex, currency = INR) - (Optional but best) Call a PSP/aggregator API to confirm the VPA is valid
- Decode the QR → you’ll almost always get
🚀 Why this is the standard
- Banks & UPI apps interoperate because NPCI mandated UPI intent URIs and EMVCo QR as the backbone.
- Paytm merchant QRs are just UPI QR with VPAs ending in
@paytm
. - PhonePe, GPay, Amazon Pay, BHIM → all issue UPI QR codes in the same format.
- Even BharatQR (card+UPI) is EMVCo TLV under the hood, but most merchant-facing QRs still decode to a UPI intent.
🔑 So in your system
- Implement UPI intent URI parsing & validation (like the PHP/Python snippets I showed).
- Treat Paytm links as a special-case allowlist (if you want to support their
paytmmp://
orhttps://paytm.me/...
). - If you want bulletproof validation (to avoid fake VPAs), plug into a PSP’s VPA verification API (Razorpay, Paytm for Business, Cashfree, PhonePe, Juspay all provide it).
👉 In short:
Decode QR → Validate UPI intent URI → (optional) Live VPA verification API.
That’s the widely accepted, real-world working approach used by payment apps and fintech companies.
I’m a DevOps/SRE/DevSecOps/Cloud Expert passionate about sharing knowledge and experiences. I have worked at Cotocus. I share tech blog at DevOps School, travel stories at Holiday Landmark, stock market tips at Stocks Mantra, health and fitness guidance at My Medic Plus, product reviews at TrueReviewNow , and SEO strategies at Wizbrand.
Do you want to learn Quantum Computing?
Please find my social handles as below;
Rajesh Kumar Personal Website
Rajesh Kumar at YOUTUBE
Rajesh Kumar at INSTAGRAM
Rajesh Kumar at X
Rajesh Kumar at FACEBOOK
Rajesh Kumar at LINKEDIN
Rajesh Kumar at WIZBRAND