Screenshot API for SaaS Platforms: Subscription Pages and Cancellation Flow Capture
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:
-
Outward trust (pricing OG images, competitor monitoring): You're building information asymmetry in your favor — being first to know when a competitor moves, presenting your pricing page well in sharing contexts.
-
Inward trust (signup evidence, cancellation documentation): You're building an evidence record that protects you in disputes. The value is realized only when something goes wrong.
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.