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:
- Accepts a document image via file upload
- Sends the image to the ScamVerify™ document analysis API using multipart/form-data
- Receives extracted entities (addresses, officials, citations, phone numbers, URLs)
- Interprets entity verification results (CMRA addresses, judge lookups, citation checks)
- Returns a structured risk assessment with recommended actions
- 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 flaskCreate a .env file with your API key:
SCAMVERIFY_API_KEY=sv_live_your_api_key_hereCreate 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.pyIn 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:
| Verification | What It Means | Risk Signal |
|---|---|---|
is_cmra: true | The 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: false | The address does not exist or is not deliverable according to USPS records. | Medium risk. Could be a typo or fabricated address. |
institution_found: false | No matching institution was found at the claimed address via Google Places. | Medium risk. The claimed organization may not operate there. |
is_government: true | Google Places confirms a government office at this address. | Positive signal, but does not guarantee the document is legitimate. |
official.found: false | The 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: true | The legal citation references a real statute or case. | Informational. Scammers frequently cite real laws to appear legitimate. |
citation.verified: false | The 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-dotenvDocument 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
- Document Analysis API Reference for full request and response schemas
- Error Handling Guide for retry strategies and error codes
- Detect Invoice and Payment Fraud for a multi-channel accounts payable screening workflow
Screen Emails for Phishing
Step-by-step tutorial for building an email phishing screening service with Node.js using the ScamVerify™ API.
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.