ScamVerify
Tutorials

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.

This tutorial walks you through building a document verification service that accepts a photo of suspicious mail and returns a detailed risk assessment. By the end, you will have a working service that uploads document images to the ScamVerify™ API, interprets entity verifications (address CMRA detection, judge lookups, legal citation checks), and returns a clear verdict.

Document scams surged in early 2026, with a wave of fake court summonses hitting residents in Virginia, Maryland, Ohio, Pennsylvania, Colorado, and Washington D.C. These notices look official but contain telltale signs that the ScamVerify™ document analysis pipeline can detect automatically.

Prerequisites

  • Python 3.9 or later (primary) or Node.js 18+ (secondary)
  • A ScamVerify™ API key (get one at scamverify.ai/settings/api)
  • A test document image (JPG, PNG, WebP, HEIC, or single-page PDF, max 4.5 MB)

What You Will Build

A document verification service that:

  1. Accepts a document image via file upload
  2. Sends the image to the ScamVerify™ document analysis API using multipart/form-data
  3. Receives extracted entities (addresses, officials, citations, phone numbers, URLs)
  4. Interprets entity verification results (CMRA addresses, judge lookups, citation checks)
  5. Returns a structured risk assessment with recommended actions
  6. Handles file validation errors and API error responses

Set up the Python project

Create a virtual environment and install dependencies.

mkdir document-verifier && cd document-verifier
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install requests python-dotenv flask

Create a .env file with your API key:

SCAMVERIFY_API_KEY=sv_live_your_api_key_here

Create the ScamVerify™ document client

The document analysis endpoint uses multipart/form-data instead of JSON. This is the only ScamVerify™ endpoint that accepts file uploads.

# scamverify.py
import requests
from dataclasses import dataclass, field
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 AddressVerification:
    address: str
    institution_claimed: Optional[str]
    address_valid: bool
    address_deliverable: bool
    is_cmra: bool
    institution_found: bool
    institution_matches: bool
    is_government: bool


@dataclass
class OfficialVerification:
    name: str
    title: str
    found: bool
    match_confidence: str  # "exact", "partial", or "none"


@dataclass
class CitationVerification:
    citation: str
    citation_type: str
    verified: Optional[bool]
    source: str


@dataclass
class DocumentResult:
    id: str
    image_hash: str
    document_type: str
    claimed_issuer: Optional[str]
    risk_score: int
    verdict: str
    confidence: float
    explanation: str
    scam_pattern: Optional[str]
    red_flags: list
    extracted_entities: dict
    entity_verifications: dict
    evidence_summary: list
    recommended_action: str
    sources_checked: list
    cached: bool

    @property
    def is_dangerous(self) -> bool:
        return self.verdict in ("high_risk", "scam")

    @property
    def has_cmra_address(self) -> bool:
        for addr in self.entity_verifications.get("addresses", []):
            if addr.get("is_cmra"):
                return True
        return False

    @property
    def missing_officials(self) -> list[str]:
        return [
            o["name"]
            for o in self.entity_verifications.get("officials", [])
            if not o.get("found")
        ]

    def get_address_verifications(self) -> list[AddressVerification]:
        return [
            AddressVerification(**a)
            for a in self.entity_verifications.get("addresses", [])
        ]

    def get_official_verifications(self) -> list[OfficialVerification]:
        return [
            OfficialVerification(**o)
            for o in self.entity_verifications.get("officials", [])
        ]

    def get_citation_verifications(self) -> list[CitationVerification]:
        return [
            CitationVerification(**c)
            for c in self.entity_verifications.get("citations", [])
        ]


class ScamVerifyClient:
    BASE_URL = "https://scamverify.ai/api/v1"

    # Accepted file types and size limit
    ACCEPTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".pdf"}
    MAX_FILE_SIZE = 4.5 * 1024 * 1024  # 4.5 MB

    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}",
        })

    def analyze_document(self, file_path: str) -> DocumentResult:
        """Upload a document image and get a risk assessment.

        Unlike other ScamVerify endpoints that use JSON,
        this endpoint requires multipart/form-data with a 'file' field.
        """
        import os

        # Validate file exists
        if not os.path.exists(file_path):
            raise FileNotFoundError(f"File not found: {file_path}")

        # Validate file extension
        ext = os.path.splitext(file_path)[1].lower()
        if ext not in self.ACCEPTED_EXTENSIONS:
            raise ValueError(
                f"Unsupported file type: {ext}. "
                f"Accepted: {', '.join(sorted(self.ACCEPTED_EXTENSIONS))}"
            )

        # Validate file size
        file_size = os.path.getsize(file_path)
        if file_size > self.MAX_FILE_SIZE:
            raise ValueError(
                f"File too large: {file_size / 1024 / 1024:.1f} MB. Maximum is 4.5 MB."
            )

        # Determine MIME type
        mime_types = {
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".png": "image/png",
            ".webp": "image/webp",
            ".heic": "image/heic",
            ".heif": "image/heif",
            ".pdf": "application/pdf",
        }
        mime_type = mime_types.get(ext, "application/octet-stream")

        # Upload using multipart/form-data (not JSON)
        with open(file_path, "rb") as f:
            files = {"file": (os.path.basename(file_path), f, mime_type)}
            response = self.session.post(
                f"{self.BASE_URL}/document/analyze",
                files=files,
            )

        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 response.status_code == 400:
            error_data = response.json().get("error", {})
            raise ScamVerifyError(error_data.get("message", "Bad request"), 400)
        if not response.ok:
            raise ScamVerifyError(f"API error: {response.status_code}", response.status_code)

        data = response.json()
        return DocumentResult(
            id=data["id"],
            image_hash=data["image_hash"],
            document_type=data["document_type"],
            claimed_issuer=data.get("claimed_issuer"),
            risk_score=data["risk_score"],
            verdict=data["verdict"],
            confidence=data.get("confidence", 0),
            explanation=data["explanation"],
            scam_pattern=data.get("scam_pattern"),
            red_flags=data.get("red_flags", []),
            extracted_entities=data.get("extracted_entities", {}),
            entity_verifications=data.get("entity_verifications", {}),
            evidence_summary=data.get("evidence_summary", []),
            recommended_action=data.get("recommended_action", ""),
            sources_checked=data.get("sources_checked", []),
            cached=data.get("cached", False),
        )

Build the verification endpoint

Create a Flask application that accepts document uploads and returns structured results.

# app.py
import os
from flask import Flask, request, jsonify
from dotenv import load_dotenv
from scamverify import ScamVerifyClient, ScamVerifyError

load_dotenv()

app = Flask(__name__)
client = ScamVerifyClient(os.environ["SCAMVERIFY_API_KEY"])

UPLOAD_DIR = "/tmp/doc-verify"
os.makedirs(UPLOAD_DIR, exist_ok=True)


def interpret_verifications(result):
    """Turn raw entity verification data into plain-English findings."""
    findings = []

    # Address verification
    for addr in result.get_address_verifications():
        if addr.is_cmra:
            findings.append({
                "type": "cmra_address",
                "severity": "high",
                "message": (
                    f"The address '{addr.address}' is a Commercial Mail Receiving Agency "
                    "(CMRA), such as a UPS Store or mailbox service. Legitimate government "
                    "agencies and courts do not use mailbox services as their address."
                ),
            })
        if not addr.address_valid:
            findings.append({
                "type": "invalid_address",
                "severity": "medium",
                "message": f"The address '{addr.address}' could not be validated as a real deliverable address.",
            })
        if addr.institution_claimed and not addr.institution_found:
            findings.append({
                "type": "institution_not_found",
                "severity": "medium",
                "message": (
                    f"No matching institution found at '{addr.address}' for "
                    f"claimed organization '{addr.institution_claimed}'."
                ),
            })
        if addr.institution_found and addr.institution_matches and addr.is_government:
            findings.append({
                "type": "institution_verified",
                "severity": "positive",
                "message": (
                    f"The claimed institution at '{addr.address}' was verified "
                    "as a government office at this location."
                ),
            })

    # Official verification
    for official in result.get_official_verifications():
        if not official.found:
            findings.append({
                "type": "official_not_found",
                "severity": "medium",
                "message": (
                    f"'{official.name}' ({official.title}) was not found in court records. "
                    "This does not confirm fraud on its own, but real court documents "
                    "typically reference officials who appear in public records."
                ),
            })
        elif official.match_confidence == "exact":
            findings.append({
                "type": "official_verified",
                "severity": "positive",
                "message": f"'{official.name}' ({official.title}) was found in court records with an exact match.",
            })

    # Citation verification
    for citation in result.get_citation_verifications():
        if citation.verified is True:
            findings.append({
                "type": "citation_verified",
                "severity": "info",
                "message": (
                    f"Citation '{citation.citation}' is a real legal reference verified "
                    f"via {citation.source}. Note: scammers often cite real statutes "
                    "to appear legitimate. A valid citation does not make the document genuine."
                ),
            })
        elif citation.verified is False:
            findings.append({
                "type": "citation_invalid",
                "severity": "high",
                "message": f"Citation '{citation.citation}' could not be verified and may be fabricated.",
            })

    return findings


@app.route("/verify", methods=["POST"])
def verify_document():
    if "file" not in request.files:
        return jsonify({"error": "No file uploaded. Send a file in the 'file' field."}), 400

    uploaded_file = request.files["file"]
    if uploaded_file.filename == "":
        return jsonify({"error": "Empty filename."}), 400

    # Save temporarily
    temp_path = os.path.join(UPLOAD_DIR, uploaded_file.filename)
    uploaded_file.save(temp_path)

    try:
        result = client.analyze_document(temp_path)
        findings = interpret_verifications(result)

        return jsonify({
            "success": True,
            "document_type": result.document_type,
            "claimed_issuer": result.claimed_issuer,
            "risk_score": result.risk_score,
            "verdict": result.verdict,
            "explanation": result.explanation,
            "scam_pattern": result.scam_pattern,
            "recommended_action": result.recommended_action,
            "red_flags": result.red_flags,
            "entity_findings": findings,
            "extracted_entities": {
                "phone_numbers": result.extracted_entities.get("phone_numbers", []),
                "urls": result.extracted_entities.get("urls", []),
                "addresses": result.extracted_entities.get("physical_addresses", []),
                "officials": result.extracted_entities.get("named_officials", []),
                "citations": result.extracted_entities.get("legal_citations", []),
            },
            "sources_checked": result.sources_checked,
            "cached": result.cached,
        })

    except ScamVerifyError as e:
        return jsonify({"error": str(e)}), e.status_code or 500
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception as e:
        return jsonify({"error": "Verification failed. Please try again."}), 500
    finally:
        # Clean up temp file
        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 upload a document image.

python app.py

In another terminal, upload a test document:

# Upload a suspicious court notice
curl -X POST http://localhost:3000/verify \
  -F "file=@suspicious-court-notice.jpg"

# Upload a toll notice
curl -X POST http://localhost:3000/verify \
  -F "file=@toll-letter.png"

# Upload a PDF document
curl -X POST http://localhost:3000/verify \
  -F "file=@irs-notice.pdf"

You can also call the ScamVerify™ API directly without the Flask wrapper:

# Direct API call with multipart/form-data
curl -X POST https://scamverify.ai/api/v1/document/analyze \
  -H "Authorization: Bearer sv_live_your_api_key_here" \
  -F "file=@suspicious-document.jpg"

Add the Node.js alternative

If you prefer Node.js, the key difference is using FormData with fetch for the multipart upload.

// scamverify.js
const fs = require('fs');
const path = require('path');

class ScamVerifyDocumentClient {
  constructor(apiKey) {
    if (!apiKey || !apiKey.startsWith('sv_')) {
      throw new Error('Invalid API key. Must start with sv_live_ or sv_test_');
    }
    this.apiKey = apiKey;
    this.baseUrl = 'https://scamverify.ai/api/v1';
  }

  async analyzeDocument(filePath) {
    const fileBuffer = fs.readFileSync(filePath);
    const fileName = path.basename(filePath);

    // Validate file size (4.5 MB max)
    if (fileBuffer.length > 4.5 * 1024 * 1024) {
      throw new Error('File too large. Maximum is 4.5 MB.');
    }

    // Build multipart form data
    const formData = new FormData();
    const blob = new Blob([fileBuffer], { type: this.getMimeType(filePath) });
    formData.append('file', blob, fileName);

    const response = await fetch(`${this.baseUrl}/document/analyze`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        // Do NOT set Content-Type. fetch sets it automatically
        // with the correct multipart boundary.
      },
      body: formData,
    });

    if (response.status === 401) throw new Error('Invalid or revoked API key');
    if (response.status === 402) throw new Error('Quota exhausted');
    if (response.status === 429) throw new Error('Rate limited');
    if (response.status === 400) {
      const err = await response.json();
      throw new Error(err.error?.message || 'Bad request');
    }
    if (!response.ok) throw new Error(`API error: ${response.status}`);

    return response.json();
  }

  getMimeType(filePath) {
    const ext = path.extname(filePath).toLowerCase();
    const types = {
      '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
      '.png': 'image/png', '.webp': 'image/webp',
      '.heic': 'image/heic', '.heif': 'image/heif',
      '.pdf': 'application/pdf',
    };
    return types[ext] || 'application/octet-stream';
  }
}

module.exports = { ScamVerifyDocumentClient };

Interpret entity verifications

The document analysis response includes rich entity verification data. Here is how to read the key fields:

VerificationWhat It MeansRisk Signal
is_cmra: trueThe address is a Commercial Mail Receiving Agency (mailbox service like UPS Store). Legitimate courts and government agencies never use mailbox services.High risk. Strong scam indicator.
address_valid: falseThe address does not exist or is not deliverable according to USPS records.Medium risk. Could be a typo or fabricated address.
institution_found: falseNo matching institution was found at the claimed address via Google Places.Medium risk. The claimed organization may not operate there.
is_government: trueGoogle Places confirms a government office at this address.Positive signal, but does not guarantee the document is legitimate.
official.found: falseThe named judge or official was not found in CourtListener court records.Medium risk. Not conclusive on its own, as some records are incomplete.
citation.verified: trueThe legal citation references a real statute or case.Informational. Scammers frequently cite real laws to appear legitimate.
citation.verified: falseThe legal citation could not be found in federal statute or case law databases.High risk. Fabricated legal references are a strong scam indicator.

Handle common error scenarios

Add validation and error handling for production use.

def verify_with_validation(client, file_path: str) -> dict:
    """Verify a document with comprehensive error handling."""
    import os

    # Pre-flight checks (before consuming API quota)
    if not os.path.exists(file_path):
        return {"error": "File not found", "action": "check_path"}

    file_size_mb = os.path.getsize(file_path) / 1024 / 1024
    if file_size_mb > 4.5:
        return {
            "error": f"File is {file_size_mb:.1f} MB. Maximum is 4.5 MB.",
            "action": "compress_or_resize",
            "hint": "Resize the image or reduce JPEG quality before uploading.",
        }

    ext = os.path.splitext(file_path)[1].lower()
    if ext not in {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".pdf"}:
        return {
            "error": f"Unsupported file type: {ext}",
            "action": "convert_format",
            "hint": "Convert the file to JPG, PNG, or PDF before uploading.",
        }

    try:
        result = client.analyze_document(file_path)

        # Build action recommendation based on risk
        if result.risk_score >= 70:
            action = "reject"
            message = "This document has strong scam indicators. Do not act on it."
        elif result.risk_score >= 40:
            action = "manual_review"
            message = "This document has some risk factors. Verify independently before acting."
        else:
            action = "accept"
            message = "No significant risk indicators found."

        return {
            "success": True,
            "action": action,
            "message": message,
            "risk_score": result.risk_score,
            "verdict": result.verdict,
            "document_type": result.document_type,
            "claimed_issuer": result.claimed_issuer,
            "has_cmra_address": result.has_cmra_address,
            "missing_officials": result.missing_officials,
            "red_flag_count": len(result.red_flags),
            "recommended_action": result.recommended_action,
        }

    except ScamVerifyError as e:
        if e.status_code == 402:
            return {"error": "Quota exhausted", "action": "upgrade_plan"}
        if e.status_code == 429:
            return {"error": "Rate limited", "action": "retry_later"}
        return {"error": str(e), "action": "retry"}

Complete Project Structure

document-verifier/
  .env                  # API key
  scamverify.py         # Client with multipart upload and result parsing
  app.py                # Flask server with /verify endpoint
  requirements.txt      # requests, flask, python-dotenv

Document analysis uses Vision AI. Each document upload runs through GPT-4o for entity extraction, then verifies addresses (Smarty), officials (CourtListener), and citations (GovInfo) in parallel. This makes document lookups more resource-intensive than other channels. Document lookups consume document quota, which is separate from phone and URL quota. Check your current usage at the usage endpoint.

Next Steps

On this page