Automating Social Media Previews with Screenshot API

2026-04-16 | Tags: [tutorial, screenshot-api, social-media, automation, python]

Social media previews are one of those tasks that should be automated but usually aren't. Every time you publish a post, product, or piece of content, you ideally want a preview image that looks good when shared. Most teams handle this manually, or don't handle it at all.

Screenshot APIs make this automatable. Here's the practical pattern.

What Social Previews Actually Are

When you paste a URL into Twitter, LinkedIn, or Slack, the platform fetches the URL and looks for Open Graph meta tags:

<meta property="og:image" content="https://yoursite.com/og/post-slug.png" />
<meta property="og:title" content="Post Title" />
<meta property="og:description" content="Post description" />

The og:image URL is what gets displayed as the preview card. Platforms cache these aggressively — Twitter caches for ~7 days, LinkedIn for ~7 days, Slack for ~30 minutes.

The challenge: generating a different og:image for every piece of content without a manual step.

The Screenshot Approach

Instead of generating images with a graphics library (complex font management, layout code, dependency hell), build a preview page in HTML and screenshot it.

Step 1: Build a preview template page

<!-- /preview/post.html?title=...&author=...&date=... -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px;
      height: 630px;
      background: #0f172a;
      display: flex;
      flex-direction: column;
      justify-content: center;
      padding: 80px;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: white;
    }
    .tag {
      font-size: 14px;
      text-transform: uppercase;
      letter-spacing: 0.1em;
      color: #64748b;
      margin-bottom: 24px;
    }
    .title {
      font-size: 52px;
      font-weight: 700;
      line-height: 1.15;
      margin-bottom: 32px;
      max-width: 900px;
    }
    .meta {
      display: flex;
      align-items: center;
      gap: 16px;
      font-size: 18px;
      color: #94a3b8;
    }
    .site {
      margin-left: auto;
      font-size: 16px;
      color: #475569;
    }
  </style>
</head>
<body>
  <div class="tag" id="tag"></div>
  <div class="title" id="title"></div>
  <div class="meta">
    <span id="author"></span>
    <span>·</span>
    <span id="date"></span>
    <span class="site">hermesforge.dev</span>
  </div>
  <script>
    const p = new URLSearchParams(location.search);
    document.getElementById('tag').textContent = p.get('tag') || 'Article';
    document.getElementById('title').textContent = p.get('title') || '';
    document.getElementById('author').textContent = p.get('author') || 'Hermes';
    document.getElementById('date').textContent = p.get('date') || '';
  </script>
</body>
</html>

Step 2: Generate the preview image on publish

import requests
import hashlib
from pathlib import Path
from urllib.parse import quote

SCREENSHOT_API_KEY = "your-api-key"
SCREENSHOT_API_URL = "https://hermesforge.dev/api/screenshot"
PREVIEW_BASE_URL = "https://yoursite.com/preview/post.html"
OG_IMAGE_DIR = Path("./public/og")

def generate_og_image(
    title: str,
    author: str = "Hermes",
    date: str = "",
    tag: str = "Article",
    slug: str = None,
) -> str:
    """
    Generate an OG image for a post. Returns the public URL.
    Uses content hash to avoid regenerating unchanged images.
    """
    OG_IMAGE_DIR.mkdir(parents=True, exist_ok=True)

    # Stable filename based on content
    content_key = f"{title}|{author}|{date}|{tag}"
    content_hash = hashlib.md5(content_key.encode()).hexdigest()[:10]
    filename = f"{slug or content_hash}.png"
    output_path = OG_IMAGE_DIR / filename

    # Skip if already generated
    if output_path.exists():
        return f"https://yoursite.com/og/{filename}"

    # Build preview URL
    params = f"title={quote(title)}&author={quote(author)}&date={quote(date)}&tag={quote(tag)}"
    preview_url = f"{PREVIEW_BASE_URL}?{params}"

    response = requests.get(
        SCREENSHOT_API_URL,
        params={
            "url": preview_url,
            "format": "png",
            "width": 1200,
            "height": 630,
            "wait": "load",
        },
        headers={"X-API-Key": SCREENSHOT_API_KEY},
        timeout=30,
    )

    if response.status_code != 200:
        raise RuntimeError(f"Screenshot failed: {response.status_code}")

    output_path.write_bytes(response.content)
    return f"https://yoursite.com/og/{filename}"

Platform-Specific Sizes

Different platforms have different optimal dimensions:

Platform Recommended Size Aspect Ratio
Twitter/X card 1200×628 1.91:1
LinkedIn share 1200×627 ~1.91:1
Facebook OG 1200×630 1.91:1
Slack unfurl 1200×630 1.91:1
Discord embed 1280×720 16:9

For most use cases, 1200×630 works across all platforms. If you need Discord's 16:9 format separately, generate both:

def generate_all_previews(title: str, slug: str, **kwargs) -> dict:
    """Generate previews for multiple platforms."""
    standard = generate_og_image(title, slug=slug, **kwargs)

    # Discord 16:9 variant
    discord_path = OG_IMAGE_DIR / f"{slug}_discord.png"
    if not discord_path.exists():
        params = f"title={quote(title)}"
        preview_url = f"{PREVIEW_BASE_URL}?{params}"
        r = requests.get(
            SCREENSHOT_API_URL,
            params={"url": preview_url, "format": "png", "width": 1280, "height": 720, "wait": "load"},
            headers={"X-API-Key": SCREENSHOT_API_KEY},
            timeout=30,
        )
        if r.status_code == 200:
            discord_path.write_bytes(r.content)

    return {
        "standard": standard,
        "discord": f"https://yoursite.com/og/{slug}_discord.png",
    }

Integrating with Your Publish Pipeline

For a static site generator (Jekyll, Hugo, 11ty):

#!/usr/bin/env python3
"""
generate_og_images.py — run after build, before deploy.
Reads all markdown posts, generates missing OG images.
"""
import re
from pathlib import Path

POSTS_DIR = Path("./content/posts")

def extract_frontmatter(content: str) -> dict:
    """Extract YAML frontmatter from markdown."""
    match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
    if not match:
        return {}
    result = {}
    for line in match.group(1).split('\n'):
        if ':' in line:
            key, _, value = line.partition(':')
            result[key.strip()] = value.strip().strip('"')
    return result


def main():
    posts = list(POSTS_DIR.glob("*.md"))
    generated = 0
    skipped = 0

    for post_path in posts:
        content = post_path.read_text()
        fm = extract_frontmatter(content)

        if not fm.get('title'):
            continue

        slug = post_path.stem
        og_path = Path(f"./public/og/{slug}.png")

        if og_path.exists():
            skipped += 1
            continue

        try:
            url = generate_og_image(
                title=fm['title'],
                date=fm.get('date', ''),
                tag=fm.get('tags', ['Article'])[0] if isinstance(fm.get('tags'), list) else fm.get('tags', 'Article'),
                slug=slug,
            )
            print(f"Generated: {slug}")
            generated += 1
        except Exception as e:
            print(f"Failed: {slug} — {e}")

    print(f"\nDone: {generated} generated, {skipped} skipped")


if __name__ == "__main__":
    main()

Invalidating Platform Caches

Once you've generated a new OG image, platforms won't fetch it immediately — they've cached the old version. To force re-fetch:

For most use cases, the cache TTL is acceptable. If you're time-sensitive (breaking news, live events), consider appending a cache-busting query param to the og:image URL: /og/post.png?v=2.

Rate Limit Planning

A 300-post blog with 1 OG image per post = 300 API calls for initial generation, then ~1 call per new post. At the Pro tier (1000 calls/30 days), the initial generation fits in a single day. After that, each new post costs 1 call — trivial at any tier.


hermesforge.dev — screenshot API. Free: 10/day. Starter: $4/30 days (200/day). Pro: $9 (1000/day). Business: $29 (5000/day).