Generate Scam Report PDFs
Build a service that generates branded PDF reports from ScamVerify™ API results using Puppeteer or WeasyPrint.
This tutorial walks you through building a PDF report generator that calls the ScamVerify™ API, formats the results into a branded HTML template, and converts it to a downloadable PDF. This is useful for compliance teams, fraud investigators, and anyone who needs to document risk assessments for internal review or regulatory purposes.
Prerequisites
- A ScamVerify™ API key (get one at scamverify.ai/settings/api)
- Node.js 18+ (Puppeteer example) or Python 3.9+ (WeasyPrint example)
What You Will Build
A report generator that:
- Accepts a phone number or URL as input
- Calls the ScamVerify™ API for a full risk assessment
- Renders the results into a branded HTML template
- Converts the HTML to a polished PDF with headers, footers, and color-coded verdicts
- Saves the PDF to disk or serves it as a download
Set up the project
mkdir scam-report-pdf && cd scam-report-pdf
npm init -y
npm install puppeteer dotenvCreate a .env file:
SCAMVERIFY_API_KEY=sv_live_your_key_heremkdir scam-report-pdf && cd scam-report-pdf
python -m venv venv
source venv/bin/activate
pip install requests weasyprint python-dotenv jinja2Create a .env file:
SCAMVERIFY_API_KEY=sv_live_your_key_hereWeasyPrint requires system dependencies. On macOS: brew install pango. On Ubuntu: apt-get install libpango-1.0-0 libpangocairo-1.0-0.
Create the ScamVerify™ client
// scamverify.js
class ScamVerifyClient {
constructor(apiKey) {
if (!apiKey || !apiKey.startsWith('sv_')) {
throw new Error('Invalid API key');
}
this.apiKey = apiKey;
this.baseUrl = 'https://scamverify.ai/api/v1';
}
async lookupPhone(phoneNumber) {
const response = await fetch(`${this.baseUrl}/phone/lookup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone_number: phoneNumber }),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
}
async lookupUrl(url) {
const response = await fetch(`${this.baseUrl}/url/lookup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
}
}
module.exports = { ScamVerifyClient };# scamverify_client.py
import requests
class ScamVerifyClient:
def __init__(self, api_key: str):
if not api_key or not api_key.startswith("sv_"):
raise ValueError("Invalid API key")
self.api_key = api_key
self.base_url = "https://scamverify.ai/api/v1"
def lookup_phone(self, phone_number: str) -> dict:
response = requests.post(
f"{self.base_url}/phone/lookup",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={"phone_number": phone_number},
timeout=30,
)
response.raise_for_status()
return response.json()
def lookup_url(self, url: str) -> dict:
response = requests.post(
f"{self.base_url}/url/lookup",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={"url": url},
timeout=30,
)
response.raise_for_status()
return response.json()Create the HTML template
This template renders the ScamVerify™ results into a branded, print-ready layout with a color-coded risk score badge, signals breakdown, and footer.
// template.js
const VERDICT_COLORS = {
safe: '#36a64f',
low_risk: '#2196F3',
medium_risk: '#FF9800',
high_risk: '#f44336',
scam: '#d32f2f',
};
function generateHtml(query, queryType, result) {
const color = VERDICT_COLORS[result.verdict] || '#808080';
const verdictLabel = result.verdict.replace('_', ' ').toUpperCase();
const confidence = Math.round(result.confidence * 100);
const date = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const signalsHtml = result.signals
? Object.entries(result.signals)
.map(([source, data]) => {
const status = typeof data === 'object'
? (data.found ? 'Found' : 'Clean')
: String(data);
const statusColor = status === 'Found' ? '#f44336' : '#36a64f';
return `
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; font-weight: 500;">
${source.replace(/_/g, ' ').toUpperCase()}
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #eee; color: ${statusColor}; font-weight: 600;">
${status}
</td>
</tr>`;
})
.join('')
: '<tr><td colspan="2" style="padding: 8px 12px;">No signal data available</td></tr>';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
color: #1a1a1a;
line-height: 1.6;
padding: 40px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #031c2e;
padding-bottom: 16px;
margin-bottom: 32px;
}
.header h1 { font-size: 22px; color: #031c2e; }
.header .date { font-size: 13px; color: #666; }
.query-box {
background: #f5f7fa;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 24px;
}
.query-label { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
.query-value { font-size: 20px; font-weight: 700; color: #031c2e; }
.verdict-row {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.verdict-card {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f5f7fa;
}
.verdict-card .value { font-size: 28px; font-weight: 800; }
.verdict-card .label { font-size: 12px; color: #666; text-transform: uppercase; margin-top: 4px; }
.section-title {
font-size: 16px;
font-weight: 700;
color: #031c2e;
margin-bottom: 12px;
margin-top: 28px;
}
.explanation {
background: #f5f7fa;
border-left: 4px solid ${color};
padding: 16px 20px;
border-radius: 0 8px 8px 0;
line-height: 1.7;
}
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
.footer {
margin-top: 40px;
padding-top: 16px;
border-top: 1px solid #ddd;
font-size: 11px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>ScamVerify\u2122 Risk Report</h1>
<div class="date">${date}</div>
</div>
<div class="query-box">
<div class="query-label">${queryType === 'phone' ? 'Phone Number' : 'URL'}</div>
<div class="query-value">${query}</div>
</div>
<div class="verdict-row">
<div class="verdict-card">
<div class="value" style="color: ${color};">${result.risk_score}</div>
<div class="label">Risk Score (0-100)</div>
</div>
<div class="verdict-card">
<div class="value" style="color: ${color};">${verdictLabel}</div>
<div class="label">Verdict</div>
</div>
<div class="verdict-card">
<div class="value">${confidence}%</div>
<div class="label">Confidence</div>
</div>
</div>
<div class="section-title">AI Risk Assessment</div>
<div class="explanation">${result.explanation}</div>
<div class="section-title">Signal Breakdown</div>
<table>
<thead>
<tr>
<th style="text-align: left; padding: 8px 12px; border-bottom: 2px solid #031c2e; font-size: 12px; text-transform: uppercase;">Source</th>
<th style="text-align: left; padding: 8px 12px; border-bottom: 2px solid #031c2e; font-size: 12px; text-transform: uppercase;">Status</th>
</tr>
</thead>
<tbody>
${signalsHtml}
</tbody>
</table>
<div class="footer">
Generated by ScamVerify\u2122 (scamverify.ai) | This report is for informational purposes only.
</div>
</body>
</html>
`;
}
module.exports = { generateHtml };Create a template.html file with Jinja2 placeholders:
<!-- template.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
color: #1a1a1a;
line-height: 1.6;
padding: 40px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #031c2e;
padding-bottom: 16px;
margin-bottom: 32px;
}
.header h1 { font-size: 22px; color: #031c2e; }
.header .date { font-size: 13px; color: #666; }
.query-box {
background: #f5f7fa;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 24px;
}
.query-label { font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
.query-value { font-size: 20px; font-weight: 700; color: #031c2e; }
.verdict-row { display: flex; gap: 24px; margin-bottom: 24px; }
.verdict-card {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f5f7fa;
}
.verdict-card .value { font-size: 28px; font-weight: 800; }
.verdict-card .label { font-size: 12px; color: #666; text-transform: uppercase; margin-top: 4px; }
.section-title {
font-size: 16px;
font-weight: 700;
color: #031c2e;
margin-bottom: 12px;
margin-top: 28px;
}
.explanation {
background: #f5f7fa;
border-left: 4px solid {{ verdict_color }};
padding: 16px 20px;
border-radius: 0 8px 8px 0;
line-height: 1.7;
}
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid #031c2e;
font-size: 12px;
text-transform: uppercase;
}
td { padding: 8px 12px; border-bottom: 1px solid #eee; }
.footer {
margin-top: 40px;
padding-top: 16px;
border-top: 1px solid #ddd;
font-size: 11px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>ScamVerify™ Risk Report</h1>
<div class="date">{{ date }}</div>
</div>
<div class="query-box">
<div class="query-label">{{ query_type }}</div>
<div class="query-value">{{ query }}</div>
</div>
<div class="verdict-row">
<div class="verdict-card">
<div class="value" style="color: {{ verdict_color }};">{{ risk_score }}</div>
<div class="label">Risk Score (0-100)</div>
</div>
<div class="verdict-card">
<div class="value" style="color: {{ verdict_color }};">{{ verdict_label }}</div>
<div class="label">Verdict</div>
</div>
<div class="verdict-card">
<div class="value">{{ confidence }}%</div>
<div class="label">Confidence</div>
</div>
</div>
<div class="section-title">AI Risk Assessment</div>
<div class="explanation">{{ explanation }}</div>
<div class="section-title">Signal Breakdown</div>
<table>
<thead>
<tr>
<th>Source</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for signal in signals %}
<tr>
<td style="font-weight: 500;">{{ signal.source }}</td>
<td style="color: {{ signal.color }}; font-weight: 600;">{{ signal.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="footer">
Generated by ScamVerify™ (scamverify.ai) | This report is for informational purposes only.
</div>
</body>
</html>Build the PDF generator
// generate-pdf.js
require('dotenv').config();
const puppeteer = require('puppeteer');
const path = require('path');
const { ScamVerifyClient } = require('./scamverify');
const { generateHtml } = require('./template');
const client = new ScamVerifyClient(process.env.SCAMVERIFY_API_KEY);
async function generateReport(query, queryType) {
console.log(`Looking up ${queryType}: ${query}...`);
// Call ScamVerify API
let result;
if (queryType === 'phone') {
result = await client.lookupPhone(query);
} else {
result = await client.lookupUrl(query);
}
console.log(`Verdict: ${result.verdict}, Score: ${result.risk_score}/100`);
// Generate HTML
const html = generateHtml(query, queryType, result);
// Convert to PDF using Puppeteer
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const filename = `scamverify-report-${queryType}-${Date.now()}.pdf`;
const outputPath = path.join(__dirname, 'reports', filename);
// Create reports directory if it does not exist
const fs = require('fs');
if (!fs.existsSync(path.join(__dirname, 'reports'))) {
fs.mkdirSync(path.join(__dirname, 'reports'));
}
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
});
await browser.close();
console.log(`PDF saved to: ${outputPath}`);
return outputPath;
}
// CLI usage
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node generate-pdf.js <phone|url> <value>');
console.log(' node generate-pdf.js phone 5551234567');
console.log(' node generate-pdf.js url https://example.com');
process.exit(1);
}
const [queryType, query] = args;
if (!['phone', 'url'].includes(queryType)) {
console.error('Type must be "phone" or "url"');
process.exit(1);
}
generateReport(query, queryType).catch((err) => {
console.error('Error:', err.message);
process.exit(1);
});# generate_pdf.py
import os
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from scamverify_client import ScamVerifyClient
load_dotenv()
VERDICT_COLORS = {
"safe": "#36a64f",
"low_risk": "#2196F3",
"medium_risk": "#FF9800",
"high_risk": "#f44336",
"scam": "#d32f2f",
}
client = ScamVerifyClient(os.environ["SCAMVERIFY_API_KEY"])
def generate_report(query: str, query_type: str) -> str:
print(f"Looking up {query_type}: {query}...")
# Call ScamVerify API
if query_type == "phone":
result = client.lookup_phone(query)
else:
result = client.lookup_url(query)
print(f"Verdict: {result['verdict']}, Score: {result['risk_score']}/100")
# Prepare template data
verdict_color = VERDICT_COLORS.get(result["verdict"], "#808080")
verdict_label = result["verdict"].replace("_", " ").upper()
confidence = round(result["confidence"] * 100)
date = datetime.now().strftime("%B %d, %Y")
# Process signals
signals = []
for source, data in (result.get("signals") or {}).items():
if isinstance(data, dict):
status = "Found" if data.get("found") else "Clean"
else:
status = str(data)
color = "#f44336" if status == "Found" else "#36a64f"
signals.append({
"source": source.replace("_", " ").upper(),
"status": status,
"color": color,
})
# Render HTML template
env = Environment(loader=FileSystemLoader(Path(__file__).parent))
template = env.get_template("template.html")
html_content = template.render(
query=query,
query_type="Phone Number" if query_type == "phone" else "URL",
risk_score=result["risk_score"],
verdict_label=verdict_label,
verdict_color=verdict_color,
confidence=confidence,
explanation=result.get("explanation", ""),
signals=signals,
date=date,
)
# Generate PDF
reports_dir = Path(__file__).parent / "reports"
reports_dir.mkdir(exist_ok=True)
filename = f"scamverify-report-{query_type}-{int(datetime.now().timestamp())}.pdf"
output_path = reports_dir / filename
HTML(string=html_content).write_pdf(str(output_path))
print(f"PDF saved to: {output_path}")
return str(output_path)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python generate_pdf.py <phone|url> <value>")
print(" python generate_pdf.py phone 5551234567")
print(" python generate_pdf.py url https://example.com")
sys.exit(1)
query_type = sys.argv[1]
query = sys.argv[2]
if query_type not in ("phone", "url"):
print('Type must be "phone" or "url"')
sys.exit(1)
generate_report(query, query_type)Generate your first report
node generate-pdf.js phone 5551234567
# Output: PDF saved to: /path/to/reports/scamverify-report-phone-1710000000.pdf
node generate-pdf.js url https://example.com
# Output: PDF saved to: /path/to/reports/scamverify-report-url-1710000001.pdfpython generate_pdf.py phone 5551234567
# Output: PDF saved to: reports/scamverify-report-phone-1710000000.pdf
python generate_pdf.py url https://example.com
# Output: PDF saved to: reports/scamverify-report-url-1710000001.pdfOpen the PDF in any viewer. You should see a branded report with the risk score, verdict, AI explanation, and signal breakdown, all formatted for print.
Add an HTTP endpoint for on-demand generation
Wrap the generator in a web server so other tools can request reports via API.
// server.js
require('dotenv').config();
const express = require('express');
const puppeteer = require('puppeteer');
const { ScamVerifyClient } = require('./scamverify');
const { generateHtml } = require('./template');
const app = express();
app.use(express.json());
const client = new ScamVerifyClient(process.env.SCAMVERIFY_API_KEY);
app.post('/report', async (req, res) => {
const { query, type } = req.body;
if (!query || !['phone', 'url'].includes(type)) {
return res.status(400).json({ error: 'Provide "query" and "type" (phone or url)' });
}
try {
const result = type === 'phone'
? await client.lookupPhone(query)
: await client.lookupUrl(query);
const html = generateHtml(query, type, result);
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
});
await browser.close();
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="scamverify-report.pdf"`,
});
res.send(pdfBuffer);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(3000, () => console.log('Report server running on port 3000'));Test it:
curl -X POST http://localhost:3000/report \
-H "Content-Type: application/json" \
-d '{"query": "5551234567", "type": "phone"}' \
--output report.pdf# server.py
import os
from flask import Flask, request, send_file
from dotenv import load_dotenv
from generate_pdf import generate_report
load_dotenv()
app = Flask(__name__)
@app.route("/report", methods=["POST"])
def create_report():
data = request.json
query = data.get("query")
query_type = data.get("type")
if not query or query_type not in ("phone", "url"):
return {"error": 'Provide "query" and "type" (phone or url)'}, 400
try:
pdf_path = generate_report(query, query_type)
return send_file(pdf_path, mimetype="application/pdf", as_attachment=True)
except Exception as e:
return {"error": str(e)}, 500
if __name__ == "__main__":
app.run(port=3000)Test it:
curl -X POST http://localhost:3000/report \
-H "Content-Type: application/json" \
-d '{"query": "5551234567", "type": "phone"}' \
--output report.pdfNext Steps
- Add batch PDF generation for multiple phone numbers or URLs at once
- Email reports automatically using Resend or SendGrid
- Add a QR code linking back to the live ScamVerify™ result page
- Store generated PDFs in S3 or Cloudflare R2 for long-term archival
- Add a watermark with the report generation timestamp for audit trails