Screenshot API for SaaS Platforms: Subscription Pages and Cancellation Flow Capture

2026-05-18 | Tags: [screenshot-api, saas, subscription, automation, tutorials]

SaaS platforms face a specific category of screenshot automation problems that other industries rarely encounter: subscription evidence. When a user claims they were charged for a plan they didn't sign up for, or that the cancellation process was unclear, the legal and support burden falls on whoever has the better evidence.

Screenshot APIs are the simplest way to build that evidence layer into your subscription infrastructure.

The SaaS Screenshot Use Cases

Pricing page capture at signup: Capture the pricing page a user saw immediately before their subscription began. Stored with the user's account creation timestamp, this documents what was offered and at what price.

Cancellation flow documentation: Regulatory pressure around dark patterns (GDPR Article 7(3), FTC's "click to cancel" rule) requires that cancellation be as easy as signup. Documenting your cancellation flow at each release proves compliance.

Competitor pricing monitoring: SaaS pricing changes constantly. When a competitor drops their price or adds a free tier, sales teams need to know before the next call with a churning customer.

Pricing page OG images: A pricing page shared on Slack or LinkedIn needs a compelling preview. Generated screenshots beat the default OpenGraph fallback.

Pricing Page Capture at Account Creation

import requests
import hashlib
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

SCREENSHOT_API = "https://hermesforge.dev/api/screenshot"
API_KEY = "your_api_key_here"

def capture_pricing_page(pricing_url: str, plan_name: str = None) -> dict:
    """Capture pricing page as shown to anonymous visitors at signup time."""
    resp = requests.get(SCREENSHOT_API, params={
        "url": pricing_url,
        "width": 1440,
        "full_page": True,
        "wait_for": "networkidle",
        "block_ads": True,
        "format": "png"  # Lossless for subscription evidence
    }, headers={"X-API-Key": API_KEY})
    resp.raise_for_status()

    image_bytes = resp.content
    return {
        "url": pricing_url,
        "plan_name": plan_name,
        "captured_at": datetime.now(timezone.utc).isoformat(),
        "sha256": hashlib.sha256(image_bytes).hexdigest(),
        "image_bytes": image_bytes
    }

class SubscriptionEvidenceStore:
    def __init__(self, db_path: str = "subscription_evidence.db",
                 evidence_dir: str = "pricing_evidence"):
        self.evidence_dir = Path(evidence_dir)
        self.evidence_dir.mkdir(exist_ok=True)
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS signup_evidence (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    user_id TEXT NOT NULL,
                    plan_id TEXT,
                    plan_name TEXT,
                    pricing_url TEXT,
                    captured_at TEXT,
                    file_path TEXT,
                    sha256 TEXT,
                    stripe_session_id TEXT
                )
            """)

    def record_signup_evidence(self, user_id: str, plan_id: str,
                                pricing_url: str, stripe_session_id: str = None) -> dict:
        """Call this when a user completes signup — capture what they saw."""
        capture = capture_pricing_page(pricing_url, plan_id)

        filename = f"signup_{user_id}_{capture['captured_at'].replace(':', '-')}.png"
        file_path = self.evidence_dir / filename
        file_path.write_bytes(capture["image_bytes"])

        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                INSERT INTO signup_evidence
                (user_id, plan_id, plan_name, pricing_url, captured_at, file_path, sha256, stripe_session_id)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """, (user_id, plan_id, capture.get("plan_name"), pricing_url,
                  capture["captured_at"], str(file_path), capture["sha256"], stripe_session_id))

        return {
            "user_id": user_id,
            "captured_at": capture["captured_at"],
            "file": str(file_path),
            "sha256": capture["sha256"]
        }

    def get_user_evidence(self, user_id: str) -> list[dict]:
        """Retrieve all signup evidence for a user (for dispute resolution)."""
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute("""
                SELECT plan_id, plan_name, pricing_url, captured_at, file_path, sha256
                FROM signup_evidence WHERE user_id = ? ORDER BY captured_at
            """, (user_id,)).fetchall()
        return [dict(zip(["plan_id", "plan_name", "url", "captured_at", "file", "sha256"], r))
                for r in rows]

Cancellation Flow Documentation

The FTC's "click-to-cancel" regulation (effective 2024) and similar EU/UK rules require that cancellation must be as simple as signup. Documenting each step of your cancellation flow at every release proves compliance:

def capture_cancellation_flow(base_url: str, cancellation_steps: list[str],
                               version_tag: str, output_dir: str = "cancellation_docs") -> dict:
    """
    Capture each step of the cancellation flow for compliance documentation.

    cancellation_steps: list of URLs for each step in the flow
    version_tag: app version or release tag (e.g. "v2.4.1")
    """
    output_path = Path(output_dir) / version_tag
    output_path.mkdir(parents=True, exist_ok=True)

    captures = []
    for i, step_url in enumerate(cancellation_steps, 1):
        resp = requests.get(SCREENSHOT_API, params={
            "url": step_url,
            "width": 1440,
            "full_page": True,
            "wait_for": "networkidle",
            "block_ads": True,
            "format": "png"
        }, headers={"X-API-Key": API_KEY})

        if resp.status_code != 200:
            captures.append({"step": i, "url": step_url, "status": "error",
                             "http_status": resp.status_code})
            continue

        image_bytes = resp.content
        filename = f"step_{i:02d}_{step_url.split('/')[-1] or 'index'}.png"
        file_path = output_path / filename
        file_path.write_bytes(image_bytes)

        captures.append({
            "step": i,
            "url": step_url,
            "status": "ok",
            "file": str(file_path),
            "sha256": hashlib.sha256(image_bytes).hexdigest(),
            "captured_at": datetime.now(timezone.utc).isoformat()
        })

    # Write manifest
    manifest = {
        "version": version_tag,
        "captured_at": datetime.now(timezone.utc).isoformat(),
        "total_steps": len(cancellation_steps),
        "successful": sum(1 for c in captures if c["status"] == "ok"),
        "steps": captures
    }

    manifest_path = output_path / "manifest.json"
    with open(manifest_path, "w") as f:
        import json
        json.dump(manifest, f, indent=2)

    return manifest

Competitor Pricing Change Detection

SaaS pricing pages change frequently and without announcement. A monitoring system that captures competitor pricing pages and detects changes gives sales and pricing teams advance notice:

import sqlite3
from PIL import Image, ImageChops
import numpy as np
import io

class CompetitorPricingMonitor:
    def __init__(self, db_path: str = "competitor_pricing.db",
                 screenshot_dir: str = "competitor_screenshots"):
        self.screenshot_dir = Path(screenshot_dir)
        self.screenshot_dir.mkdir(exist_ok=True)
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS pricing_pages (
                    id TEXT PRIMARY KEY,
                    name TEXT,
                    url TEXT,
                    last_checked TEXT,
                    last_hash TEXT,
                    change_count INTEGER DEFAULT 0
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS pricing_snapshots (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    page_id TEXT,
                    captured_at TEXT,
                    file_path TEXT,
                    image_hash TEXT,
                    change_detected BOOLEAN DEFAULT 0
                )
            """)

    def add_competitor(self, page_id: str, name: str, pricing_url: str):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "INSERT OR IGNORE INTO pricing_pages (id, name, url) VALUES (?, ?, ?)",
                (page_id, name, pricing_url)
            )

    def check_competitor(self, page_id: str) -> dict:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT url, last_hash, name FROM pricing_pages WHERE id = ?",
                (page_id,)
            ).fetchone()

        if not row:
            return {"error": "unknown page_id"}

        url, prev_hash, name = row

        # Suppress dynamic elements: pricing badges, countdown timers, live chat
        stability_css = """
            [class*="countdown"], [class*="timer"], [class*="chat"],
            [class*="badge"][class*="new"], .intercom-launcher,
            [id*="hubspot"], .drift-widget { display: none !important; }
            * { animation: none !important; transition: none !important; }
        """

        resp = requests.get(SCREENSHOT_API, params={
            "url": url,
            "width": 1440,
            "full_page": True,
            "wait_for": "networkidle",
            "block_ads": True,
            "inject_css": stability_css,
            "format": "webp"
        }, headers={"X-API-Key": API_KEY})

        if resp.status_code != 200:
            return {"page_id": page_id, "error": f"HTTP {resp.status_code}"}

        image_bytes = resp.content
        image_hash = hashlib.sha256(image_bytes).hexdigest()
        timestamp = datetime.now(timezone.utc).isoformat()
        change_detected = prev_hash is not None and prev_hash != image_hash

        filename = f"{page_id}_{timestamp.replace(':', '-')}.webp"
        file_path = self.screenshot_dir / filename
        file_path.write_bytes(image_bytes)

        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                INSERT INTO pricing_snapshots (page_id, captured_at, file_path, image_hash, change_detected)
                VALUES (?, ?, ?, ?, ?)
            """, (page_id, timestamp, str(file_path), image_hash, change_detected))

            conn.execute("""
                UPDATE pricing_pages SET last_checked = ?, last_hash = ?,
                    change_count = change_count + ? WHERE id = ?
            """, (timestamp, image_hash, 1 if change_detected else 0, page_id))

        return {
            "page_id": page_id,
            "name": name,
            "change_detected": change_detected,
            "file": str(file_path)
        }

    def monitor_all(self) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            page_ids = [r[0] for r in conn.execute("SELECT id FROM pricing_pages").fetchall()]
        results = [self.check_competitor(pid) for pid in page_ids]
        return results

    def get_changes(self) -> list[dict]:
        """Return only pages where changes were detected in the last check."""
        return [r for r in self.monitor_all() if r.get("change_detected")]

Pricing Page OG Image Generation

When your pricing page URL is shared on Slack, LinkedIn, or Twitter, the default OpenGraph preview often renders a partial or unstyled view. Generated screenshots solve this:

def generate_pricing_og_image(pricing_url: str,
                               output_path: str = "pricing_og.webp",
                               cache_hours: int = 6) -> str:
    """
    Generate a 1200x630 OG preview of the pricing page.

    Cache for 6h by default — pricing pages change, but not that often.
    """
    cached = Path(output_path)
    if cached.exists():
        age_hours = (datetime.now(timezone.utc).timestamp() - cached.stat().st_mtime) / 3600
        if age_hours < cache_hours:
            return str(cached)

    # Inject CSS to highlight the pricing cards and hide the navigation
    pricing_focus_css = """
        header, nav, footer, .hero, .testimonials, .faq { display: none !important; }
        #pricing, .pricing, [class*="pricing"] { margin-top: 0 !important; }
    """

    resp = requests.get(SCREENSHOT_API, params={
        "url": pricing_url,
        "width": 1200,
        "height": 630,
        "full_page": False,
        "wait_for": "networkidle",
        "block_ads": True,
        "inject_css": pricing_focus_css,
        "format": "webp"
    }, headers={"X-API-Key": API_KEY})
    resp.raise_for_status()

    cached.write_bytes(resp.content)
    return str(cached)

Integrating With Your Signup Webhook

The signup evidence capture fits naturally into a Stripe webhook handler:

# In your Stripe webhook handler (Flask/FastAPI/etc.)
def handle_checkout_session_completed(session: dict):
    user_id = session["metadata"].get("user_id")
    plan_id = session["metadata"].get("plan_id")
    pricing_url = f"https://yourapp.com/pricing"

    # Capture evidence synchronously before responding to webhook
    evidence_store = SubscriptionEvidenceStore()
    evidence = evidence_store.record_signup_evidence(
        user_id=user_id,
        plan_id=plan_id,
        pricing_url=pricing_url,
        stripe_session_id=session["id"]
    )

    # Log evidence reference alongside subscription record
    save_to_database(
        user_id=user_id,
        plan_id=plan_id,
        pricing_evidence_sha256=evidence["sha256"],
        pricing_evidence_file=evidence["file"]
    )

Rate Limit Planning for SaaS Workloads

Workload Frequency Volume Calls/day Tier
Signup evidence (low-volume SaaS) Per signup ~20/day 20 Free (50/day)
Cancellation flow docs (quarterly) Per release 5 steps ~1/day avg Free
Competitor monitoring (5 competitors) Every 4h 5 pages 30 Free
High-volume signup evidence Per signup ~200/day 200 Starter ($4)
Competitor monitoring + signup (mid-size) Combined ~500/day 500 Pro ($9)

What SaaS Distinguishes

SaaS screenshot automation splits cleanly across two different trust directions:

Both need to run automatically, on different triggers (signup webhook vs. cron schedule), and both use the same screenshot API with different parameter sets.


Hermesforge Screenshot API: full-page and viewport capture, PNG and WebP, JavaScript injection support. Get a free API key — 50 calls/day, no signup required.