Screenshot API for Real Estate Portals: Automating Property Listing Capture

2026-05-18 | Tags: [screenshot-api, real-estate, automation, tutorials]

Real estate portals live or die on visual content. A listing without images gets ignored. A sharing link with a blank OG preview gets skipped. A sold property that disappears without an archive is a gap in transaction history.

Screenshot APIs solve a category of real estate automation problems that dedicated image hosting pipelines can't: capturing the listing page itself as a visual artifact — with all its layout, branding, and contextual information intact.

The Real Estate Screenshot Use Cases

OG preview generation for listings — when an agent shares a property link via SMS or WhatsApp, the link preview determines click-through. Generating a clean 1200×630 screenshot of the listing page gives you a shareable preview without a separate image pipeline.

Listing status change monitoring — when a competitor's listing changes from "Available" to "Under Offer" or "Sale Agreed", you want to know. Hash-based change detection on screenshots catches layout changes, status badge changes, and price adjustments.

Archive capture for compliance — property transactions require documented evidence of what was advertised. A screenshot of the listing at the time of offer, cryptographically timestamped, satisfies advertising compliance requirements.

Portfolio capture for agents — agents building their track record need visual evidence of listings they sold. Automated portfolio screenshot capture from sold properties before they're removed from portals.

Setting Up the Listing Capture Pipeline

import requests
import hashlib
import sqlite3
import json
from datetime import datetime
from pathlib import Path

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

def capture_listing(listing_url: str, viewport_width: int = 1200) -> bytes:
    """Capture a full listing page screenshot."""
    resp = requests.get(SCREENSHOT_API, params={
        "url": listing_url,
        "width": viewport_width,
        "full_page": True,
        "block_ads": True,
        "wait_for": "networkidle",
        "format": "webp"
    }, headers={"X-API-Key": API_KEY})
    resp.raise_for_status()
    return resp.content

def capture_og_preview(listing_url: str) -> bytes:
    """Capture a 1200x630 OG-optimized preview."""
    resp = requests.get(SCREENSHOT_API, params={
        "url": listing_url,
        "width": 1200,
        "height": 630,
        "full_page": False,
        "block_ads": True,
        "wait_for": "networkidle",
        "format": "webp"
    }, headers={"X-API-Key": API_KEY})
    resp.raise_for_status()
    return resp.content

Listing Registry with Change Detection

import sqlite3
from pathlib import Path

class ListingRegistry:
    def __init__(self, db_path: str = "listings.db", screenshot_dir: str = "screenshots"):
        self.db_path = db_path
        self.screenshot_dir = Path(screenshot_dir)
        self.screenshot_dir.mkdir(exist_ok=True)
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS listings (
                    id TEXT PRIMARY KEY,
                    url TEXT NOT NULL,
                    address TEXT,
                    status TEXT DEFAULT 'active',
                    last_captured TEXT,
                    last_hash TEXT,
                    first_seen TEXT DEFAULT CURRENT_TIMESTAMP,
                    change_count INTEGER DEFAULT 0
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS snapshots (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    listing_id TEXT,
                    captured_at TEXT,
                    file_path TEXT,
                    image_hash TEXT,
                    change_detected BOOLEAN DEFAULT 0,
                    FOREIGN KEY (listing_id) REFERENCES listings(id)
                )
            """)

    def add_listing(self, listing_id: str, url: str, address: str = None):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "INSERT OR IGNORE INTO listings (id, url, address) VALUES (?, ?, ?)",
                (listing_id, url, address)
            )

    def record_snapshot(self, listing_id: str, image_bytes: bytes) -> dict:
        image_hash = hashlib.sha256(image_bytes).hexdigest()
        timestamp = datetime.utcnow().isoformat()

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

        with sqlite3.connect(self.db_path) as conn:
            # Get previous hash
            row = conn.execute(
                "SELECT last_hash FROM listings WHERE id = ?",
                (listing_id,)
            ).fetchone()

            prev_hash = row[0] if row else None
            change_detected = prev_hash is not None and prev_hash != image_hash

            # Record snapshot
            conn.execute("""
                INSERT INTO snapshots (listing_id, captured_at, file_path, image_hash, change_detected)
                VALUES (?, ?, ?, ?, ?)
            """, (listing_id, timestamp, str(file_path), image_hash, change_detected))

            # Update listing record
            conn.execute("""
                UPDATE listings
                SET last_captured = ?, last_hash = ?,
                    change_count = change_count + ?
                WHERE id = ?
            """, (timestamp, image_hash, 1 if change_detected else 0, listing_id))

        return {
            "listing_id": listing_id,
            "change_detected": change_detected,
            "hash": image_hash,
            "file": str(file_path)
        }

OG Preview Automation for Listing Shares

When a listing is published or shared, generate the OG preview and cache it. Most property portals generate OG images server-side on first request — you can pregenerate them at publish time instead:

import os
from pathlib import Path

class ListingOGPipeline:
    def __init__(self, output_dir: str = "og_previews"):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)

    def generate_og_preview(self, listing_id: str, listing_url: str) -> str:
        """Generate and cache OG preview. Returns file path."""
        cache_path = self.output_dir / f"{listing_id}_og.webp"

        # Return cached version if recent (< 24h)
        if cache_path.exists():
            age_hours = (datetime.utcnow().timestamp() - cache_path.stat().st_mtime) / 3600
            if age_hours < 24:
                return str(cache_path)

        image_bytes = capture_og_preview(listing_url)
        cache_path.write_bytes(image_bytes)
        return str(cache_path)

    def batch_generate(self, listings: list[dict]) -> dict:
        """Generate OG previews for a batch of listings."""
        results = {}
        for listing in listings:
            try:
                path = self.generate_og_preview(listing["id"], listing["url"])
                results[listing["id"]] = {"status": "ok", "path": path}
            except Exception as e:
                results[listing["id"]] = {"status": "error", "error": str(e)}
        return results

Status Change Detection and Alerting

The most commercially valuable screenshot use case for real estate is competitive monitoring: detecting when competitor listings change status (Available → Under Offer → Sold) before the data hits aggregators.

import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from PIL import Image, ImageChops
import numpy as np
import io

def generate_diff_highlight(before_bytes: bytes, after_bytes: bytes) -> bytes:
    """Produce a diff image with changed areas highlighted in red."""
    before = Image.open(io.BytesIO(before_bytes)).convert("RGB")
    after = Image.open(io.BytesIO(after_bytes)).convert("RGB")

    # Resize to match if dimensions differ
    if before.size != after.size:
        after = after.resize(before.size, Image.LANCZOS)

    diff = ImageChops.difference(before, after)
    diff_array = np.array(diff)

    # Threshold: pixels changed by >15 in any channel
    changed_mask = diff_array.max(axis=2) > 15

    # Create highlight overlay
    after_array = np.array(after.copy())
    after_array[changed_mask] = [255, 60, 60]  # Red highlight

    result = Image.fromarray(after_array.astype(np.uint8))
    output = io.BytesIO()
    result.save(output, format="PNG")
    return output.getvalue()

def send_change_alert(listing_id: str, listing_url: str, address: str,
                      before_bytes: bytes, after_bytes: bytes,
                      smtp_host: str, smtp_port: int,
                      smtp_user: str, smtp_pass: str,
                      alert_recipients: list[str]):
    """Send visual diff alert when a listing changes."""
    diff_bytes = generate_diff_highlight(before_bytes, after_bytes)

    msg = MIMEMultipart("related")
    msg["Subject"] = f"Listing Change Detected: {address}"
    msg["From"] = smtp_user
    msg["To"] = ", ".join(alert_recipients)

    html_body = f"""
    <html><body>
    <h2>Listing Change Detected</h2>
    <p><strong>Address:</strong> {address}</p>
    <p><strong>URL:</strong> <a href="{listing_url}">{listing_url}</a></p>
    <p><strong>Detected at:</strong> {datetime.utcnow().isoformat()}Z</p>
    <h3>Visual Diff (changed areas in red)</h3>
    <img src="cid:diff_image" style="max-width:100%">
    </body></html>
    """

    msg.attach(MIMEText(html_body, "html"))
    diff_img = MIMEImage(diff_bytes, name="diff.png")
    diff_img.add_header("Content-ID", "<diff_image>")
    msg.attach(diff_img)

    with smtplib.SMTP_SSL(smtp_host, smtp_port) as smtp:
        smtp.login(smtp_user, smtp_pass)
        smtp.sendmail(smtp_user, alert_recipients, msg.as_string())

Compliance Archive at Offer Time

When a buyer makes an offer on a property, capture the listing as it appeared at that moment. This is the screenshot compliance pattern from our earlier post, applied to a specific real estate workflow:

import hmac
import hashlib
import json
from datetime import datetime, timezone

class ListingArchive:
    def __init__(self, archive_dir: str, secret_key: bytes):
        self.archive_dir = Path(archive_dir)
        self.archive_dir.mkdir(exist_ok=True)
        self.secret_key = secret_key
        self.manifest_path = self.archive_dir / "manifest.jsonl"

    def archive_at_offer(self, listing_id: str, listing_url: str,
                          offer_amount: int, buyer_reference: str) -> dict:
        """Capture listing screenshot at time of offer submission."""
        timestamp = datetime.now(timezone.utc).isoformat()
        image_bytes = capture_listing(listing_url)
        image_hash = hashlib.sha256(image_bytes).hexdigest()

        # Save screenshot
        filename = f"{listing_id}_offer_{timestamp.replace(':', '-')}.webp"
        file_path = self.archive_dir / filename
        file_path.write_bytes(image_bytes)

        # Build manifest entry
        entry = {
            "listing_id": listing_id,
            "url": listing_url,
            "captured_at": timestamp,
            "offer_amount": offer_amount,
            "buyer_reference": buyer_reference,
            "file": filename,
            "sha256": image_hash
        }

        # HMAC chain: sign this entry + previous entry's HMAC
        prev_hmac = self._get_last_hmac()
        chain_input = json.dumps(entry) + (prev_hmac or "")
        entry["hmac"] = hmac.new(
            self.secret_key,
            chain_input.encode(),
            hashlib.sha256
        ).hexdigest()

        with open(self.manifest_path, "a") as f:
            f.write(json.dumps(entry) + "\n")

        return entry

    def _get_last_hmac(self) -> str | None:
        if not self.manifest_path.exists():
            return None
        last_line = None
        with open(self.manifest_path) as f:
            for line in f:
                last_line = line.strip()
        if last_line:
            return json.loads(last_line).get("hmac")
        return None

Agent Portfolio Capture

Real estate agents frequently lose visual evidence of listings they sold when the portal removes the listing. Automated portfolio capture runs periodically against an agent's active listings and archives them before removal:

def capture_agent_portfolio(agent_id: str, active_listing_urls: list[str],
                             portfolio_dir: str = "agent_portfolios") -> dict:
    """Capture full-page screenshots of all agent's active listings."""
    output_dir = Path(portfolio_dir) / agent_id
    output_dir.mkdir(parents=True, exist_ok=True)

    results = []
    for url in active_listing_urls:
        try:
            image = capture_listing(url, viewport_width=1440)
            # Hash URL for stable filename
            url_hash = hashlib.sha256(url.encode()).hexdigest()[:12]
            timestamp = datetime.utcnow().strftime("%Y%m%d")
            filename = f"listing_{url_hash}_{timestamp}.webp"
            (output_dir / filename).write_bytes(image)
            results.append({"url": url, "status": "captured", "file": filename})
        except Exception as e:
            results.append({"url": url, "status": "error", "error": str(e)})

    return {"agent_id": agent_id, "captured": len([r for r in results if r["status"] == "captured"]),
            "errors": len([r for r in results if r["status"] == "error"]), "results": results}

Rate Limit Planning for Real Estate Workloads

Workload Capture frequency Listings Calls/day Recommended tier
Single agent portfolio Daily 20 20 Free (50/day)
Small portal monitoring Hourly 50 1,200 Pro (1000/day)
Regional portal 30-minute 200 9,600 Business (5000/day)
National portal 15-minute 500 48,000 Enterprise contact

Combine with caching (our previous post) to reduce calls by 60-80% for stable listings — price and status changes are infrequent, so TTL of 1-4 hours is appropriate for most listings.

What This Covers

The real estate pipeline demonstrates four distinct screenshot API workflows in a single domain:

  1. OG preview generation — visual metadata for link sharing
  2. Change detection monitoring — competitive intelligence via layout/status diffing
  3. Compliance archiving — tamper-evident evidence capture at transaction time
  4. Portfolio automation — preemptive capture before portal removal

The underlying pattern is the same across all four: the listing page as a canonical visual artifact. Screenshot APIs capture what was present, not what the database says was present. That distinction matters in property disputes, advertising compliance reviews, and agent portfolios more than almost any other industry.


Hermesforge Screenshot API handles JavaScript rendering, ad blocking, and full-page capture. Get a free API key — 50 screenshots/day, no signup required.