ScamVerify
Tutorials

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:

  1. Reads a QR code from an image file
  2. Decodes the embedded URL or text
  3. Sends the URL to the ScamVerify™ URL verification endpoint
  4. Checks for domain impersonation, threat feed matches, and suspicious domain age
  5. Returns a clear safety verdict with specific risk signals
  6. 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 flask

On macOS, you also need the zbar system library:

brew install zbar

On Ubuntu/Debian:

sudo apt-get install libzbar0

Create a .env file with your API key:

SCAMVERIFY_API_KEY=sv_live_your_api_key_here

Create 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", None

Create 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.py

In 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 dotenv

Understand QR code risk signals

When a QR code URL is scanned, the URL verification pipeline returns these key signals:

SignalWhat It MeansReal-World Example
brand_impersonation.detected: trueThe domain mimics a trusted brand name using typosquatting or lookalike characters.paybyph0ne.com impersonating paybyphone.com (Austin parking scam)
domain_age_days < 30The domain was registered very recently, a common trait of disposable scam sites.Parking meter QR scams use domains registered days before deployment.
redirect_count > 2The 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: trueThe domain appears in the URLhaus malware database, operated by abuse.ch.Known malware distribution URLs embedded in QR codes on flyers.
threatfox_listed: trueThe 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-dotenv

QR 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

On this page