{"id":53412,"date":"2025-09-22T13:19:01","date_gmt":"2025-09-22T13:19:01","guid":{"rendered":"https:\/\/www.devopsschool.com\/blog\/?p=53412"},"modified":"2025-09-22T13:19:01","modified_gmt":"2025-09-22T13:19:01","slug":"how-to-validate-upi-paytm-payment-qr-codes-programmatically-step-by-step-guide","status":"publish","type":"post","link":"https:\/\/www.devopsschool.com\/blog\/how-to-validate-upi-paytm-payment-qr-codes-programmatically-step-by-step-guide\/","title":{"rendered":"How to Validate UPI &amp; Paytm Payment QR Codes Programmatically (Step-by-Step Guide"},"content":{"rendered":"\n<p>Great question. You can validate \u201cpayment QR codes\u201d at <strong>two levels<\/strong>:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Decode &amp; structural checks (what you can do locally in code)<\/strong><\/li>\n\n\n\n<li><strong>Live verification (needs a PSP\/gateway API; optional but best-practice)<\/strong><\/li>\n<\/ol>\n\n\n\n<p>Below is a practical, copy-pasteable approach you can drop into your stack (PHP\/Laravel, plus quick Node\/Python snippets).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">1) What kinds of payment QR payloads you\u2019ll see<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>UPI intent URI<\/strong> (most common; including Paytm VPAs)<br>Example:<br><code>upi:\/\/pay?pa=merchant@paytm&amp;pn=Acme+Store&amp;am=123.45&amp;cu=INR&amp;tn=Order+123<\/code><\/li>\n\n\n\n<li><strong>EMVCo \/ BharatQR TLV string<\/strong> (starts with digits)<br>Example:<br><code>000201010211...5303561540...6304ABCD<\/code> (ends with CRC \u201c63 04 XXXX\u201d)<\/li>\n\n\n\n<li><strong>Wallet\/app-specific deep links<\/strong>\n<ul class=\"wp-block-list\">\n<li>Paytm: <code>upi:\/\/pay?...<\/code> <strong>or<\/strong> <code>paytmmp:\/\/...<\/code> <strong>or<\/strong> short URL like <code>https:\/\/paytm.me\/...<\/code><br>(Most Paytm merchant QRs are actually UPI with a Paytm VPA such as <code>...@paytm<\/code>.)<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">2) Flow to implement<\/h1>\n\n\n\n<p><strong>A. Decode the QR image \u2192 get the raw text<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>PHP: <code>khanamiryan\/qrcode-detector-decoder<\/code> (ZXing port)<\/li>\n\n\n\n<li>Node: <code>@zxing\/library<\/code>, <code>jsqr<\/code><\/li>\n\n\n\n<li>Python: <code>pyzbar<\/code> or <code>opencv<\/code> + <code>zbarlight<\/code><\/li>\n<\/ul>\n\n\n\n<p><strong>B. Decide the type &amp; validate structurally<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>If it <strong>starts with <code>upi:\/\/pay<\/code><\/strong> \u2192 parse as URI, validate required params.<\/li>\n\n\n\n<li>If it <strong>looks like TLV (digits, starts with \u201c00 02 01 02 11 \u2026\u201d etc.)<\/strong> \u2192 parse TLV and verify <strong>CRC16-CCITT<\/strong> in tag <code>63<\/code>.<\/li>\n\n\n\n<li>If it\u2019s <strong>app link<\/strong> (<code>paytmmp:\/\/<\/code> or <code>https:\/\/paytm.me\/...<\/code>) \u2192 allowlist the scheme\/domain and (optionally) resolve server-side to ensure it\u2019s a valid, reachable link owned by Paytm.<\/li>\n<\/ul>\n\n\n\n<p><strong>C. (Optional but recommended) Live verification<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>You <strong>cannot<\/strong> guarantee a VPA exists or is active from the QR alone.<\/li>\n\n\n\n<li>Use a PSP\/gateway that exposes <strong>VPA verification<\/strong> (aka \u201cUPI PSP lookup\u201d) APIs:\n<ul class=\"wp-block-list\">\n<li>Razorpay, Cashfree, Paytm for Business, PhonePe, Juspay, etc.<\/li>\n\n\n\n<li>Typically you send <code>pa=merchant@bank<\/code> to an endpoint \u2192 they reply if it\u2019s valid and sometimes return the account holder name.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">3) Validation rules (UPI intent)<\/h1>\n\n\n\n<p>For <code>upi:\/\/pay<\/code> links:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Scheme<\/strong> must be <code>upi<\/code>, <strong>host<\/strong> must be <code>pay<\/code><\/li>\n\n\n\n<li><strong>Required<\/strong>:\n<ul class=\"wp-block-list\">\n<li><code>pa<\/code> (VPA): <code>^[a-zA-Z0-9.\\-_]{2,}@[a-zA-Z0-9.\\-_]{2,}$<\/code><\/li>\n\n\n\n<li><code>cu<\/code> (currency): usually <code>INR<\/code><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li><strong>Common\/optional<\/strong>: <code>pn<\/code> (payee name), <code>am<\/code> (amount), <code>tn<\/code> (note), <code>tr<\/code> (txn ref), <code>tid<\/code>, <code>mc<\/code> (merchant code), <code>orgid<\/code>, <code>sign<\/code> (signed intents)<\/li>\n\n\n\n<li>If you <strong>require fixed amount<\/strong> QRs, ensure <code>am<\/code> is present and numeric (<code>^\\d+(\\.\\d{1,2})?$<\/code>).<\/li>\n\n\n\n<li>Reject unknown params if you want a strict policy, or allow a whitelist (<code>pa,pn,am,tn,tr,tid,mc,cu,orgid,sign,mode<\/code>).<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">4) Validation rules (EMVCo\/BharatQR TLV)<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li>TLV format: <code>&lt;TAG>&lt;LEN>&lt;VALUE><\/code> repeated, all numeric.<\/li>\n\n\n\n<li><strong>CRC<\/strong> is tag <code>63<\/code> with length <code>04<\/code>. Compute <strong>CRC16-CCITT (0x1021, init 0xFFFF)<\/strong> over the bytes <strong>up to but not including<\/strong> the CRC value, then compare with the provided 4-hex checksum.<\/li>\n\n\n\n<li>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.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">5) PHP (Laravel-friendly) samples<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">A) Decode the QR<\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">composer <span class=\"hljs-built_in\">require<\/span> khanamiryan\/qrcode-detector-decoder\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-keyword\">use<\/span> <span class=\"hljs-title\">Zxing<\/span>\\<span class=\"hljs-title\">QrReader<\/span>;\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">decodeQrImage<\/span><span class=\"hljs-params\">(string $path)<\/span>: ?<span class=\"hljs-title\">string<\/span> <\/span>{\n    $qrcode = <span class=\"hljs-keyword\">new<\/span> QrReader($path); <span class=\"hljs-comment\">\/\/ supports JPG\/PNG, etc.<\/span>\n    $text = $qrcode-&gt;text();\n    <span class=\"hljs-keyword\">return<\/span> $text !== <span class=\"hljs-string\">''<\/span> ? $text : <span class=\"hljs-keyword\">null<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">B) Validate UPI intent URI<\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">isValidUpiUri<\/span><span class=\"hljs-params\">(string $s)<\/span>: <span class=\"hljs-title\">bool<\/span> <\/span>{\n    <span class=\"hljs-comment\">\/\/ quick precheck<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (!str_starts_with($s, <span class=\"hljs-string\">'upi:\/\/pay'<\/span>)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    $parts = parse_url($s);\n    <span class=\"hljs-keyword\">if<\/span> (($parts&#91;<span class=\"hljs-string\">'scheme'<\/span>] ?? <span class=\"hljs-string\">''<\/span>) !== <span class=\"hljs-string\">'upi'<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n    <span class=\"hljs-keyword\">if<\/span> (($parts&#91;<span class=\"hljs-string\">'host'<\/span>] ?? <span class=\"hljs-string\">''<\/span>) !== <span class=\"hljs-string\">'pay'<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    parse_str($parts&#91;<span class=\"hljs-string\">'query'<\/span>] ?? <span class=\"hljs-string\">''<\/span>, $q);\n\n    <span class=\"hljs-comment\">\/\/ Required fields<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">empty<\/span>($q&#91;<span class=\"hljs-string\">'pa'<\/span>]) || <span class=\"hljs-keyword\">empty<\/span>($q&#91;<span class=\"hljs-string\">'cu'<\/span>])) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    <span class=\"hljs-comment\">\/\/ VPA pattern (basic)<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (!preg_match(<span class=\"hljs-string\">'\/^&#91;A-Za-z0-9._-]+@&#91;A-Za-z0-9._-]+$\/'<\/span>, $q&#91;<span class=\"hljs-string\">'pa'<\/span>])) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    <span class=\"hljs-comment\">\/\/ Currency<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (strtoupper($q&#91;<span class=\"hljs-string\">'cu'<\/span>]) !== <span class=\"hljs-string\">'INR'<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    <span class=\"hljs-comment\">\/\/ Amount (if present)<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">isset<\/span>($q&#91;<span class=\"hljs-string\">'am'<\/span>]) &amp;&amp; !preg_match(<span class=\"hljs-string\">'\/^\\d+(\\.\\d{1,2})?$\/'<\/span>, $q&#91;<span class=\"hljs-string\">'am'<\/span>])) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    <span class=\"hljs-comment\">\/\/ Optional: enforce allowlist of params<\/span>\n    $allowed = &#91;<span class=\"hljs-string\">'pa'<\/span>,<span class=\"hljs-string\">'pn'<\/span>,<span class=\"hljs-string\">'am'<\/span>,<span class=\"hljs-string\">'tn'<\/span>,<span class=\"hljs-string\">'tr'<\/span>,<span class=\"hljs-string\">'tid'<\/span>,<span class=\"hljs-string\">'mc'<\/span>,<span class=\"hljs-string\">'cu'<\/span>,<span class=\"hljs-string\">'orgid'<\/span>,<span class=\"hljs-string\">'sign'<\/span>,<span class=\"hljs-string\">'mode'<\/span>];\n    <span class=\"hljs-keyword\">foreach<\/span> (array_keys($q) <span class=\"hljs-keyword\">as<\/span> $k) {\n        <span class=\"hljs-keyword\">if<\/span> (!in_array($k, $allowed, <span class=\"hljs-keyword\">true<\/span>)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>; <span class=\"hljs-comment\">\/\/ tighten as you prefer<\/span>\n    }\n\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">true<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">C) Detect &amp; validate EMVCo\/BharatQR TLV (CRC check)<\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">looksLikeTlv<\/span><span class=\"hljs-params\">(string $s)<\/span>: <span class=\"hljs-title\">bool<\/span> <\/span>{\n    <span class=\"hljs-keyword\">return<\/span> ctype_digit($s) &amp;&amp; strlen($s) &gt;= <span class=\"hljs-number\">8<\/span>; <span class=\"hljs-comment\">\/\/ rough heuristic<\/span>\n}\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">parseTlv<\/span><span class=\"hljs-params\">(string $s)<\/span>: <span class=\"hljs-title\">array<\/span> <\/span>{\n    $i = <span class=\"hljs-number\">0<\/span>; $fields = &#91;];\n    <span class=\"hljs-keyword\">while<\/span> ($i + <span class=\"hljs-number\">4<\/span> &lt;= strlen($s)) {\n        $tag = substr($s, $i, <span class=\"hljs-number\">2<\/span>); $i += <span class=\"hljs-number\">2<\/span>;\n        $len = intval(substr($s, $i, <span class=\"hljs-number\">2<\/span>)); $i += <span class=\"hljs-number\">2<\/span>;\n        $val = substr($s, $i, $len); $i += $len;\n        $fields&#91;$tag] = $val;\n        <span class=\"hljs-keyword\">if<\/span> ($i &gt; strlen($s)) <span class=\"hljs-keyword\">break<\/span>;\n    }\n    <span class=\"hljs-keyword\">return<\/span> $fields;\n}\n\n<span class=\"hljs-comment\">\/\/ CRC16-CCITT (0x1021), init 0xFFFF, no xorout<\/span>\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">crc16_ccitt<\/span><span class=\"hljs-params\">(string $data)<\/span>: <span class=\"hljs-title\">string<\/span> <\/span>{\n    $crc = <span class=\"hljs-number\">0xFFFF<\/span>;\n    $bytes = unpack(<span class=\"hljs-string\">'C*'<\/span>, $data);\n    <span class=\"hljs-keyword\">foreach<\/span> ($bytes <span class=\"hljs-keyword\">as<\/span> $b) {\n        $crc ^= ($b &lt;&lt; <span class=\"hljs-number\">8<\/span>);\n        <span class=\"hljs-keyword\">for<\/span> ($i=<span class=\"hljs-number\">0<\/span>; $i&lt;<span class=\"hljs-number\">8<\/span>; $i++) {\n            $crc = ($crc &amp; <span class=\"hljs-number\">0x8000<\/span>) ? (($crc &lt;&lt; <span class=\"hljs-number\">1<\/span>) ^ <span class=\"hljs-number\">0x1021<\/span>) : ($crc &lt;&lt; <span class=\"hljs-number\">1<\/span>);\n            $crc &amp;= <span class=\"hljs-number\">0xFFFF<\/span>;\n        }\n    }\n    <span class=\"hljs-keyword\">return<\/span> strtoupper(str_pad(dechex($crc), <span class=\"hljs-number\">4<\/span>, <span class=\"hljs-string\">'0'<\/span>, STR_PAD_LEFT));\n}\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">isValidEmvcoWithCrc<\/span><span class=\"hljs-params\">(string $s)<\/span>: <span class=\"hljs-title\">bool<\/span> <\/span>{\n    <span class=\"hljs-keyword\">if<\/span> (!looksLikeTlv($s)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n    <span class=\"hljs-comment\">\/\/ Find CRC tag \"63\"<\/span>\n    $pos = strpos($s, <span class=\"hljs-string\">'63'<\/span>);\n    <span class=\"hljs-keyword\">if<\/span> ($pos === <span class=\"hljs-keyword\">false<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">false<\/span>;\n\n    <span class=\"hljs-comment\">\/\/ The CRC field is \"63 04 XXXX\"<\/span>\n    $crcFieldStart = $pos;\n    $dataForCrc = substr($s, <span class=\"hljs-number\">0<\/span>, $crcFieldStart + <span class=\"hljs-number\">4<\/span>); <span class=\"hljs-comment\">\/\/ include \"63\" + \"04\", exclude the 4 hex CRC value<\/span>\n    $providedCrc = substr($s, $crcFieldStart + <span class=\"hljs-number\">4<\/span>, <span class=\"hljs-number\">4<\/span>);\n    <span class=\"hljs-comment\">\/\/ Compute CRC over the raw bytes, which here are ASCII digits; this simplified check is<\/span>\n    <span class=\"hljs-comment\">\/\/ acceptable for gating malformed QRs. For production, convert TLV to actual bytes.<\/span>\n    $calc = crc16_ccitt($dataForCrc);\n    <span class=\"hljs-keyword\">return<\/span> strtoupper($providedCrc) === $calc;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">D) Putting it together<\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">validatePaymentQr<\/span><span class=\"hljs-params\">(string $imagePath)<\/span>: <span class=\"hljs-title\">array<\/span> <\/span>{\n    $raw = decodeQrImage($imagePath);\n    <span class=\"hljs-keyword\">if<\/span> ($raw === <span class=\"hljs-keyword\">null<\/span>) {\n        <span class=\"hljs-keyword\">return<\/span> &#91;<span class=\"hljs-string\">'ok'<\/span> =&gt; <span class=\"hljs-keyword\">false<\/span>, <span class=\"hljs-string\">'type'<\/span> =&gt; <span class=\"hljs-keyword\">null<\/span>, <span class=\"hljs-string\">'reason'<\/span> =&gt; <span class=\"hljs-string\">'QR not readable'<\/span>];\n    }\n\n    <span class=\"hljs-comment\">\/\/ UPI intent?<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (isValidUpiUri($raw)) {\n        <span class=\"hljs-keyword\">return<\/span> &#91;<span class=\"hljs-string\">'ok'<\/span> =&gt; <span class=\"hljs-keyword\">true<\/span>, <span class=\"hljs-string\">'type'<\/span> =&gt; <span class=\"hljs-string\">'upi-intent'<\/span>, <span class=\"hljs-string\">'payload'<\/span> =&gt; $raw];\n    }\n\n    <span class=\"hljs-comment\">\/\/ EMVCo TLV with valid CRC?<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (looksLikeTlv($raw) &amp;&amp; isValidEmvcoWithCrc($raw)) {\n        <span class=\"hljs-keyword\">return<\/span> &#91;<span class=\"hljs-string\">'ok'<\/span> =&gt; <span class=\"hljs-keyword\">true<\/span>, <span class=\"hljs-string\">'type'<\/span> =&gt; <span class=\"hljs-string\">'emvco-tlv'<\/span>, <span class=\"hljs-string\">'payload'<\/span> =&gt; $raw];\n    }\n\n    <span class=\"hljs-comment\">\/\/ Paytm deep links (allowlist)<\/span>\n    <span class=\"hljs-keyword\">if<\/span> (str_starts_with($raw, <span class=\"hljs-string\">'paytmmp:\/\/'<\/span>) || str_starts_with($raw, <span class=\"hljs-string\">'https:\/\/paytm.me\/'<\/span>)) {\n        <span class=\"hljs-keyword\">return<\/span> &#91;<span class=\"hljs-string\">'ok'<\/span> =&gt; <span class=\"hljs-keyword\">true<\/span>, <span class=\"hljs-string\">'type'<\/span> =&gt; <span class=\"hljs-string\">'paytm-link'<\/span>, <span class=\"hljs-string\">'payload'<\/span> =&gt; $raw];\n    }\n\n    <span class=\"hljs-keyword\">return<\/span> &#91;<span class=\"hljs-string\">'ok'<\/span> =&gt; <span class=\"hljs-keyword\">false<\/span>, <span class=\"hljs-string\">'type'<\/span> =&gt; <span class=\"hljs-keyword\">null<\/span>, <span class=\"hljs-string\">'reason'<\/span> =&gt; <span class=\"hljs-string\">'Unknown or malformed payment QR'<\/span>];\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">6) Node &amp; Python quickies<\/h1>\n\n\n\n<p><strong>Node (decode + basic UPI check)<\/strong><\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">npm<\/span> <span class=\"hljs-selector-tag\">i<\/span> <span class=\"hljs-keyword\">@zxing<\/span>\/library\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> { BrowserQRCodeReader } = <span class=\"hljs-built_in\">require<\/span>(<span class=\"hljs-string\">'@zxing\/library'<\/span>);\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">isValidUpiUri<\/span>(<span class=\"hljs-params\">s<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">try<\/span> {\n    <span class=\"hljs-keyword\">const<\/span> u = <span class=\"hljs-keyword\">new<\/span> URL(s);\n    <span class=\"hljs-keyword\">if<\/span> (u.protocol !== <span class=\"hljs-string\">'upi:'<\/span> || u.hostname !== <span class=\"hljs-string\">'pay'<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">false<\/span>;\n    <span class=\"hljs-keyword\">const<\/span> pa = u.searchParams.get(<span class=\"hljs-string\">'pa'<\/span>);\n    <span class=\"hljs-keyword\">const<\/span> cu = u.searchParams.get(<span class=\"hljs-string\">'cu'<\/span>);\n    <span class=\"hljs-keyword\">if<\/span> (!pa || !<span class=\"hljs-regexp\">\/^&#91;A-Za-z0-9._-]+@&#91;A-Za-z0-9._-]+$\/<\/span>.test(pa)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">false<\/span>;\n    <span class=\"hljs-keyword\">if<\/span> ((cu || <span class=\"hljs-string\">''<\/span>).toUpperCase() !== <span class=\"hljs-string\">'INR'<\/span>) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">false<\/span>;\n    <span class=\"hljs-keyword\">const<\/span> am = u.searchParams.get(<span class=\"hljs-string\">'am'<\/span>);\n    <span class=\"hljs-keyword\">if<\/span> (am &amp;&amp; !<span class=\"hljs-regexp\">\/^\\d+(\\.\\d{1,2})?$\/<\/span>.test(am)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">false<\/span>;\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">true<\/span>;\n  } <span class=\"hljs-keyword\">catch<\/span> { <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">false<\/span>; }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p><strong>Python (pyzbar)<\/strong><\/p>\n\n\n<pre class=\"wp-block-code\"><span><code class=\"hljs\">pip install pyzbar pillow\n<\/code><\/span><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">from<\/span> pyzbar.pyzbar <span class=\"hljs-keyword\">import<\/span> decode\n<span class=\"hljs-keyword\">from<\/span> PIL <span class=\"hljs-keyword\">import<\/span> Image\n<span class=\"hljs-keyword\">from<\/span> urllib.parse <span class=\"hljs-keyword\">import<\/span> urlparse, parse_qs\n\ndef is_valid_upi_uri(s: str) -&gt; bool:\n    <span class=\"hljs-keyword\">try<\/span>:\n        u = urlparse(s)\n        <span class=\"hljs-keyword\">if<\/span> u.scheme != <span class=\"hljs-string\">'upi'<\/span> or u.netloc != <span class=\"hljs-string\">'pay'<\/span>: <span class=\"hljs-keyword\">return<\/span> False\n        q = {<span class=\"hljs-attr\">k<\/span>:v&#91;<span class=\"hljs-number\">0<\/span>] <span class=\"hljs-keyword\">for<\/span> k,v <span class=\"hljs-keyword\">in<\/span> parse_qs(u.query).items()}\n        pa = q.get(<span class=\"hljs-string\">'pa'<\/span>); cu = (q.get(<span class=\"hljs-string\">'cu'<\/span>) or <span class=\"hljs-string\">''<\/span>).upper()\n        <span class=\"hljs-keyword\">if<\/span> not pa or not cu: <span class=\"hljs-keyword\">return<\/span> False\n        <span class=\"hljs-keyword\">import<\/span> re\n        <span class=\"hljs-keyword\">if<\/span> not re.match(r<span class=\"hljs-string\">'^&#91;A-Za-z0-9._-]+@&#91;A-Za-z0-9._-]+$'<\/span>, pa): <span class=\"hljs-keyword\">return<\/span> False\n        <span class=\"hljs-keyword\">if<\/span> cu != <span class=\"hljs-string\">'INR'<\/span>: <span class=\"hljs-keyword\">return<\/span> False\n        am = q.get(<span class=\"hljs-string\">'am'<\/span>)\n        <span class=\"hljs-keyword\">if<\/span> am and not re.match(r<span class=\"hljs-string\">'^\\d+(\\.\\d{1,2})?$'<\/span>, am): <span class=\"hljs-keyword\">return<\/span> False\n        <span class=\"hljs-keyword\">return<\/span> True\n    <span class=\"hljs-attr\">except<\/span>:\n        <span class=\"hljs-keyword\">return<\/span> False\n\ndef decode_qr(path: str) -&gt; str | None:\n    d = decode(Image.open(path))\n    <span class=\"hljs-keyword\">return<\/span> d&#91;<span class=\"hljs-number\">0<\/span>].data.decode() <span class=\"hljs-keyword\">if<\/span> d <span class=\"hljs-keyword\">else<\/span> None\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">7) Security &amp; product tips<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Don\u2019t auto-trust<\/strong> a decoded string just because it parses. Enforce <strong>strict allowlists<\/strong> (schemes, hosts, param names).<\/li>\n\n\n\n<li><strong>Sanitize &amp; log<\/strong> the original payload for audit\/debug.<\/li>\n\n\n\n<li><strong>Optionally<\/strong>: if you only accept <strong>merchant (fixed-amount) QRs<\/strong>, reject those missing <code>am<\/code>.<\/li>\n\n\n\n<li><strong>Live verification<\/strong>: integrate a PSP\u2019s <strong>VPA validation<\/strong> API to confirm the VPA exists and is active before saving the QR to your database.<\/li>\n\n\n\n<li><strong>Rate-limit<\/strong> and virus-scan uploads; store images in a safe bucket; validate <strong>mime &amp; dimensions<\/strong>.<\/li>\n\n\n\n<li><strong>UX<\/strong>: after upload, show the decoded details (VPA, name, amount) back to the user for confirmation.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>If you want something that is <strong>widely used in production and proven to work across banks, UPI apps, and wallets like Paytm<\/strong>, then the industry practice is:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u2705 Most Widely Accepted Implementation<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>UPI intent URI validation + (optionally) EMVCo TLV validation + VPA verification via PSP APIs.<\/strong><\/li>\n\n\n\n<li>Almost all real-world QR codes in India today (Paytm, PhonePe, GPay, BHIM, BharatPe, banks) follow the <strong>NPCI UPI QR Standard<\/strong> which is <strong>based on EMVCo QR<\/strong> but typically presented as a <code>upi:\/\/pay?...<\/code> intent.<\/li>\n\n\n\n<li>So, in practice:\n<ol class=\"wp-block-list\">\n<li><strong>Decode the QR \u2192 you\u2019ll almost always get <code>upi:\/\/pay?...<\/code><\/strong><\/li>\n\n\n\n<li><strong>Check required fields (<code>pa<\/code>, <code>cu<\/code>) + format (VPA regex, currency = INR)<\/strong><\/li>\n\n\n\n<li><strong>(Optional but best) Call a PSP\/aggregator API to confirm the VPA is valid<\/strong><\/li>\n<\/ol>\n<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">\ud83d\ude80 Why this is the standard<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Banks &amp; UPI apps<\/strong> interoperate because NPCI mandated UPI intent URIs and EMVCo QR as the backbone.<\/li>\n\n\n\n<li><strong>Paytm merchant QRs<\/strong> are just UPI QR with VPAs ending in <code>@paytm<\/code>.<\/li>\n\n\n\n<li><strong>PhonePe, GPay, Amazon Pay, BHIM<\/strong> \u2192 all issue UPI QR codes in the same format.<\/li>\n\n\n\n<li>Even BharatQR (card+UPI) is EMVCo TLV under the hood, but most merchant-facing QRs still decode to a UPI intent.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">\ud83d\udd11 So in your system<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Implement <strong>UPI intent URI parsing &amp; validation<\/strong> (like the PHP\/Python snippets I showed).<\/li>\n\n\n\n<li>Treat <strong>Paytm links<\/strong> as a special-case allowlist (if you want to support their <code>paytmmp:\/\/<\/code> or <code>https:\/\/paytm.me\/...<\/code>).<\/li>\n\n\n\n<li>If you want bulletproof validation (to avoid fake VPAs), <strong>plug into a PSP\u2019s VPA verification API<\/strong> (Razorpay, Paytm for Business, Cashfree, PhonePe, Juspay all provide it).<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>\ud83d\udc49 In short:<br><strong>Decode QR \u2192 Validate UPI intent URI \u2192 (optional) Live VPA verification API.<\/strong><br>That\u2019s the <strong>widely accepted, real-world working approach<\/strong> used by payment apps and fintech companies.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Great question. You can validate \u201cpayment QR codes\u201d at two levels: Below is a practical, copy-pasteable approach you can drop into your stack (PHP\/Laravel, plus quick Node\/Python snippets). 1) What&#8230; <\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"_joinchat":[],"footnotes":""},"categories":[2],"tags":[],"class_list":["post-53412","post","type-post","status-publish","format-standard","hentry","category-uncategorised"],"_links":{"self":[{"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/posts\/53412","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/comments?post=53412"}],"version-history":[{"count":1,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/posts\/53412\/revisions"}],"predecessor-version":[{"id":53413,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/posts\/53412\/revisions\/53413"}],"wp:attachment":[{"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/media?parent=53412"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/categories?post=53412"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.devopsschool.com\/blog\/wp-json\/wp\/v2\/tags?post=53412"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}