Automating Social Media Previews with Screenshot API
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:
- Twitter/X: Use the Card Validator to scrape fresh metadata
- LinkedIn: Use the Post Inspector to clear the cache
- Facebook: Use the Sharing Debugger to scrape fresh
- Slack: No manual tool — cache expires in ~30 minutes automatically
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).