ScamVerify™
Tutorials

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:

  1. Accepts a phone number or URL as input
  2. Calls the ScamVerify™ API for a full risk assessment
  3. Renders the results into a branded HTML template
  4. Converts the HTML to a polished PDF with headers, footers, and color-coded verdicts
  5. 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 dotenv

Create a .env file:

SCAMVERIFY_API_KEY=sv_live_your_key_here
mkdir scam-report-pdf && cd scam-report-pdf
python -m venv venv
source venv/bin/activate
pip install requests weasyprint python-dotenv jinja2

Create a .env file:

SCAMVERIFY_API_KEY=sv_live_your_key_here

WeasyPrint 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&#8482; 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&#8482; (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.pdf
python 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.pdf

Open 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.pdf

Next 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

On this page