Build a QR Code Safety Scanner
Step-by-step tutorial for building a QR code safety scanner that decodes QR images and checks embedded URLs for scam indicators using the ScamVerify™ API.
This tutorial walks you through building a QR code safety scanner that decodes QR codes from images and checks the embedded URL against the ScamVerify™ threat intelligence database. By the end, you will have a working tool that extracts URLs from QR code photos, runs them through the URL verification pipeline, and flags dangerous links before anyone clicks them.
QR code scams are accelerating. In Austin, Texas, city officials discovered 29 compromised parking meters with fraudulent QR stickers overlaying legitimate payment codes. The FBI IC3 issued a public service announcement in July 2025 warning consumers about QR codes in unexpected packages. Restaurant menu QR scams redirect diners to phishing pages that harvest payment card data.
Prerequisites
- Python 3.9 or later (primary) or Node.js 18+ (secondary)
- A ScamVerify™ API key (get one at scamverify.ai/settings/api)
- A QR code image for testing
What You Will Build
A QR code safety scanner that:
- Reads a QR code from an image file
- Decodes the embedded URL or text
- Sends the URL to the ScamVerify™ URL verification endpoint
- Checks for domain impersonation, threat feed matches, and suspicious domain age
- Returns a clear safety verdict with specific risk signals
- Handles non-URL QR codes and decode failures
Set up the Python project
Create a virtual environment and install the QR decoding and HTTP dependencies.
mkdir qr-scanner && cd qr-scanner
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install pyzbar Pillow requests python-dotenv flaskOn macOS, you also need the zbar system library:
brew install zbarOn Ubuntu/Debian:
sudo apt-get install libzbar0Create a .env file with your API key:
SCAMVERIFY_API_KEY=sv_live_your_api_key_hereCreate the QR decoder
Build a module that extracts URLs from QR code images. The QR decoding happens entirely on the client side. There is no separate QR endpoint in the ScamVerify™ API. You decode the QR code locally, then send the extracted URL to the standard URL lookup endpoint.
# qr_decoder.py
from pyzbar.pyzbar import decode
from PIL import Image
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
import re
@dataclass
class QRDecodeResult:
raw_data: str
is_url: bool
url: Optional[str]
qr_type: str # "url", "text", "phone", "email", "wifi", "other"
def decode_qr(image_path: str) -> list[QRDecodeResult]:
"""Decode all QR codes found in an image.
Returns a list because some images may contain multiple QR codes
(e.g., a photo of a wall with several posted QR codes).
"""
image = Image.open(image_path)
decoded_objects = decode(image)
results = []
for obj in decoded_objects:
raw = obj.data.decode("utf-8", errors="replace")
qr_type, url = classify_qr_content(raw)
results.append(QRDecodeResult(
raw_data=raw,
is_url=(qr_type == "url"),
url=url,
qr_type=qr_type,
))
return results
def classify_qr_content(data: str) -> tuple[str, Optional[str]]:
"""Classify QR content and extract URL if present."""
stripped = data.strip()
# Direct URL
if stripped.startswith("http://") or stripped.startswith("https://"):
return "url", stripped
# URL without scheme (common in QR codes)
if re.match(r"^[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}", stripped) and " " not in stripped:
return "url", f"https://{stripped}"
# Phone number (tel: scheme)
if stripped.startswith("tel:"):
return "phone", None
# Email (mailto: scheme)
if stripped.startswith("mailto:"):
return "email", None
# WiFi config
if stripped.startswith("WIFI:"):
return "wifi", None
return "other", NoneCreate the ScamVerify™ URL client
Build a client for the URL lookup endpoint. QR code scans use the standard URL verification pipeline, which means they consume URL quota (not document quota).
# scamverify.py
import requests
from dataclasses import dataclass
from typing import Optional
class ScamVerifyError(Exception):
def __init__(self, message: str, status_code: int = 0):
super().__init__(message)
self.status_code = status_code
@dataclass
class UrlScanResult:
risk_score: int
verdict: str
explanation: str
signals: dict
cached: bool
@property
def is_safe(self) -> bool:
return self.verdict in ("safe", "low_risk")
@property
def is_dangerous(self) -> bool:
return self.verdict in ("high_risk", "scam")
@property
def brand_impersonation(self) -> Optional[dict]:
return self.signals.get("brand_impersonation")
@property
def domain_age_days(self) -> Optional[int]:
return self.signals.get("domain_age_days")
@property
def redirect_count(self) -> int:
return self.signals.get("redirect_count", 0)
@property
def threat_feeds_matched(self) -> list[str]:
feeds = []
if self.signals.get("urlhaus_listed"):
feeds.append("URLhaus")
if self.signals.get("threatfox_listed"):
feeds.append("ThreatFox")
return feeds
class ScamVerifyClient:
BASE_URL = "https://scamverify.ai/api/v1"
def __init__(self, api_key: str):
if not api_key or not api_key.startswith("sv_"):
raise ValueError("Invalid API key. Keys must start with sv_live_ or sv_test_")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
})
def lookup_url(self, url: str) -> UrlScanResult:
response = self.session.post(
f"{self.BASE_URL}/url/lookup",
json={"url": url},
)
if response.status_code == 401:
raise ScamVerifyError("Invalid or revoked API key", 401)
if response.status_code == 402:
raise ScamVerifyError("Quota exhausted. Upgrade your plan or wait for reset.", 402)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After", "60")
raise ScamVerifyError(f"Rate limited. Retry after {retry_after} seconds.", 429)
if not response.ok:
raise ScamVerifyError(f"API error: {response.status_code}", response.status_code)
data = response.json()
return UrlScanResult(
risk_score=data["risk_score"],
verdict=data["verdict"],
explanation=data["explanation"],
signals=data.get("signals", {}),
cached=data.get("cached", False),
)Build the scanner application
Wire the QR decoder and ScamVerify™ client together into a Flask application.
# app.py
import os
from flask import Flask, request, jsonify
from dotenv import load_dotenv
from qr_decoder import decode_qr
from scamverify import ScamVerifyClient, ScamVerifyError
load_dotenv()
app = Flask(__name__)
client = ScamVerifyClient(os.environ["SCAMVERIFY_API_KEY"])
UPLOAD_DIR = "/tmp/qr-scanner"
os.makedirs(UPLOAD_DIR, exist_ok=True)
def check_domain_impersonation(signals: dict) -> Optional[dict]:
"""Extract brand impersonation details from URL scan signals."""
bi = signals.get("brand_impersonation")
if bi and bi.get("detected"):
return {
"detected": True,
"brand": bi.get("brand", "Unknown"),
"confidence": bi.get("confidence", 0),
"warning": (
f"This URL may be impersonating {bi['brand']}. "
"The domain name is visually similar to a trusted brand."
),
}
return None
def assess_qr_risk(url: str, scan_result) -> dict:
"""Build a comprehensive risk assessment from the URL scan."""
assessment = {
"url": url,
"risk_score": scan_result.risk_score,
"verdict": scan_result.verdict,
"explanation": scan_result.explanation,
"safe_to_visit": scan_result.is_safe,
"risk_factors": [],
}
# Domain age check
if scan_result.domain_age_days is not None:
if scan_result.domain_age_days < 30:
assessment["risk_factors"].append({
"factor": "new_domain",
"detail": f"Domain registered {scan_result.domain_age_days} day(s) ago.",
"severity": "high" if scan_result.domain_age_days < 7 else "medium",
})
# Redirect chain
if scan_result.redirect_count > 2:
assessment["risk_factors"].append({
"factor": "excessive_redirects",
"detail": f"URL redirects {scan_result.redirect_count} times before reaching the final page.",
"severity": "medium",
})
# Threat feed matches
for feed in scan_result.threat_feeds_matched:
assessment["risk_factors"].append({
"factor": "threat_feed_match",
"detail": f"URL found in {feed} threat database.",
"severity": "high",
})
# Brand impersonation
impersonation = check_domain_impersonation(scan_result.signals)
if impersonation:
assessment["risk_factors"].append({
"factor": "brand_impersonation",
"detail": impersonation["warning"],
"severity": "high",
})
assessment["brand_impersonation"] = impersonation
return assessment
@app.route("/scan", methods=["POST"])
def scan_qr():
"""Upload a QR code image, decode it, and check the URL."""
if "file" not in request.files:
return jsonify({"error": "No file uploaded. Send an image in the 'file' field."}), 400
uploaded_file = request.files["file"]
if uploaded_file.filename == "":
return jsonify({"error": "Empty filename."}), 400
temp_path = os.path.join(UPLOAD_DIR, uploaded_file.filename)
uploaded_file.save(temp_path)
try:
# Step 1: Decode QR code(s) from the image
qr_results = decode_qr(temp_path)
if not qr_results:
return jsonify({
"success": False,
"error": "No QR code found in the image.",
"hint": "Make sure the QR code is clearly visible and not blurry.",
}), 400
# Step 2: Check each URL found
assessments = []
for qr in qr_results:
if qr.is_url and qr.url:
try:
scan_result = client.lookup_url(qr.url)
assessment = assess_qr_risk(qr.url, scan_result)
assessments.append(assessment)
except ScamVerifyError as e:
assessments.append({
"url": qr.url,
"error": str(e),
"safe_to_visit": False,
})
else:
assessments.append({
"raw_data": qr.raw_data,
"type": qr.qr_type,
"note": f"QR code contains {qr.qr_type} data, not a URL. No URL scan performed.",
})
# Determine overall safety
any_dangerous = any(
a.get("risk_score", 0) >= 50
for a in assessments
if "risk_score" in a
)
return jsonify({
"success": True,
"qr_codes_found": len(qr_results),
"overall_safe": not any_dangerous,
"results": assessments,
})
except Exception as e:
return jsonify({"error": f"Scan failed: {str(e)}"}), 500
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
@app.route("/health")
def health():
return jsonify({"status": "ok"})
if __name__ == "__main__":
app.run(debug=True, port=3000)Test with cURL
Start the server and scan a QR code image.
python app.pyIn another terminal:
# Scan a QR code image
curl -X POST http://localhost:3000/scan \
-F "file=@parking-qr-code.jpg"
# Scan a restaurant menu QR code
curl -X POST http://localhost:3000/scan \
-F "file=@menu-qr.png"You can also call the ScamVerify™ URL lookup API directly if you already have the decoded URL:
# Direct URL check (no QR decoding needed)
curl -X POST https://scamverify.ai/api/v1/url/lookup \
-H "Authorization: Bearer sv_live_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"url": "https://paybyph0ne.com/pay?meter=4821"}'Add the Node.js alternative
Here is the QR decode and URL check flow in Node.js using jsqr and sharp.
// qr-scanner.js
const jsQR = require('jsqr');
const sharp = require('sharp');
async function decodeQR(imagePath) {
// Convert image to raw RGBA pixel data
const { data, info } = await sharp(imagePath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const qr = jsQR(new Uint8ClampedArray(data), info.width, info.height);
if (!qr) {
return null;
}
return qr.data;
}
async function scanQRAndCheck(imagePath, apiKey) {
// Step 1: Decode QR code locally
const qrData = await decodeQR(imagePath);
if (!qrData) {
return { success: false, error: 'No QR code found in image.' };
}
// Step 2: Check if it is a URL
const isUrl = qrData.startsWith('http://') || qrData.startsWith('https://');
if (!isUrl) {
return {
success: true,
qr_data: qrData,
type: 'non_url',
note: 'QR code does not contain a URL. No safety check performed.',
};
}
// Step 3: Send URL to ScamVerify URL Lookup endpoint
const response = await fetch('https://scamverify.ai/api/v1/url/lookup', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: qrData }),
});
if (!response.ok) {
const errorMessages = {
401: 'Invalid or revoked API key',
402: 'Quota exhausted',
429: 'Rate limited',
};
throw new Error(errorMessages[response.status] || `API error: ${response.status}`);
}
const result = await response.json();
return {
success: true,
url: qrData,
risk_score: result.risk_score,
verdict: result.verdict,
explanation: result.explanation,
safe_to_visit: result.verdict === 'safe' || result.verdict === 'low_risk',
brand_impersonation: result.signals?.brand_impersonation || null,
domain_age_days: result.signals?.domain_age_days || null,
redirect_count: result.signals?.redirect_count || 0,
};
}
module.exports = { decodeQR, scanQRAndCheck };Install the dependencies:
npm install jsqr sharp dotenvUnderstand QR code risk signals
When a QR code URL is scanned, the URL verification pipeline returns these key signals:
| Signal | What It Means | Real-World Example |
|---|---|---|
brand_impersonation.detected: true | The domain mimics a trusted brand name using typosquatting or lookalike characters. | paybyph0ne.com impersonating paybyphone.com (Austin parking scam) |
domain_age_days < 30 | The domain was registered very recently, a common trait of disposable scam sites. | Parking meter QR scams use domains registered days before deployment. |
redirect_count > 2 | The URL bounces through multiple redirects before reaching the final page, often to obscure the true destination. | Package QR codes routing through 3 to 4 tracking domains before a phishing page. |
urlhaus_listed: true | The domain appears in the URLhaus malware database, operated by abuse.ch. | Known malware distribution URLs embedded in QR codes on flyers. |
threatfox_listed: true | The domain appears in the ThreatFox indicator of compromise (IOC) database. | QR codes linking to command-and-control infrastructure. |
google_web_risk: "SOCIAL_ENGINEERING" | Google classifies the URL as a social engineering (phishing) threat. | Restaurant menu QR codes swapped with phishing pages. |
Complete Project Structure
qr-scanner/
.env # API key
qr_decoder.py # QR code image decoding with pyzbar
scamverify.py # URL lookup API client
app.py # Flask server with /scan endpoint
requirements.txt # pyzbar, Pillow, requests, flask, python-dotenvQR code scans use URL quota. Since QR codes simply contain URLs, scanning a QR code consumes one URL lookup from your quota, not a document lookup. This makes QR scanning significantly cheaper than document analysis. If you are processing QR codes at scale, consider the Batch URL Lookup endpoint to check up to 100 decoded URLs in a single request.
Next Steps
- URL Verification API Reference for full request and response schemas
- Add URL Scanning to Your App for a deeper dive on URL verification
- Verify Suspicious Mail and Documents for scanning physical documents that contain QR codes
Verify Suspicious Mail and Documents
Step-by-step tutorial for building a document verification service that detects fake court notices, toll letters, and IRS scams using the ScamVerify™ API.
Detect Invoice and Payment Fraud
Step-by-step tutorial for building an accounts payable screening tool that verifies invoices using multi-channel ScamVerify™ API lookups before processing payment.