Python screenshot API
Capture web pages from Python with requests or httpx, including async batches and retries.
The Python alternative to running Selenium or Playwright in your own container: one HTTP call, no browser to install, no driver to keep in step with Chrome.
Examples use requests; the httpx equivalents are at the bottom.
The minimal call
import os
import requests
response = requests.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json={"url": "https://example.com", "format": "png"},
timeout=60,
)
response.raise_for_status()
with open("example.png", "wb") as file:
file.write(response.content)Set a timeout. Without one, requests waits forever, and a capture can legitimately
take up to the 30-second render timeout — longer if the page is slow and you added a
delay.
Errors are JSON even when you asked for PNG
A failed capture returns a JSON body with the same Content-Type habits as any other
error, so response.content on a failure is an error object, not an image. Branch on the
status before writing anything:
if response.status_code != 200:
payload = response.json()
raise RuntimeError(f"ssnap {payload.get('code')}: {payload.get('error')}")code is stable and safe to compare against; error is human prose. The full table is in
API error codes.
Streaming large captures
A full-page render of a long document can be tens of megabytes. Stream it rather than buffering:
with requests.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json={"url": "https://example.com", "full_page": True, "network_idle": True},
stream=True,
timeout=60,
) as response:
response.raise_for_status()
with open("full.jpg", "wb") as file:
for chunk in response.iter_content(chunk_size=64 * 1024):
file.write(chunk)Capturing a batch
The per-minute rate limit applies per API key, so a naive for loop over a thousand urls
will hit 429 partway through and lose whatever it had not written yet. Two habits make
batches survivable:
import time
def capture(params, attempt=0):
response = requests.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json=params,
timeout=60,
)
if response.status_code == 429:
code = response.json().get("code")
# The monthly quota does not reset inside a retry window. Only the
# per-minute rate limit is worth waiting out.
if code == "quota_exceeded" or attempt >= 3:
return response
time.sleep(int(response.headers.get("retry-after", 5)))
return capture(params, attempt + 1)
return response
for target in urls:
result = capture({"url": target, "format": "png", "cache_ttl": 86400})cache_ttl in a batch is not just speed: a cache hit does not consume a monthly capture
slot, so re-running a failed batch costs nothing for the urls that already succeeded.
See Screenshot caching.
Async with httpx
For genuinely concurrent work, cap the concurrency yourself — your key's per-minute allowance is the real ceiling, not your event loop.
import asyncio
import os
import httpx
LIMIT = asyncio.Semaphore(5)
async def capture(client: httpx.AsyncClient, url: str) -> bytes:
async with LIMIT:
response = await client.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json={"url": url, "format": "png", "cache_ttl": 3600},
timeout=60.0,
)
if response.status_code != 200:
raise RuntimeError(response.json().get("code", "unknown"))
return response.content
async def main(urls: list[str]) -> list[bytes]:
async with httpx.AsyncClient() as client:
return await asyncio.gather(*(capture(client, url) for url in urls))Check X-RateLimit-Remaining on the responses to tune that semaphore against your actual
plan — see Rate limits and quotas.
Links instead of bytes
response = requests.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json={"url": "https://example.com", "response": "url", "cache_ttl": 3600},
timeout=30,
)
payload = response.json()
print(payload["url"], payload["cached"])The signed link lasts 24 hours and carries no credentials, so it can go straight into a template. Beyond that window, re-host the file yourself.
Long jobs belong in a queue
Rendering a heavy report inside a Django or Flask request ties up a worker for the whole
render. Pass a callback_url instead: the API returns 202 immediately and POSTs the
result to your endpoint when it is done, with a 120-second job timeout rather than 30.
requests.post(
"https://ssnap.cc/api/v1/screenshot",
headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"},
json={
"url": "https://example.com/reports/q1",
"format": "pdf",
"network_idle": True,
"callback_url": "https://your-app.com/hooks/ssnap",
},
timeout=30,
)Verify the signature on that webhook before acting on it — your endpoint is public. See Verify webhook signatures.
Things that catch people out
full_pageplus lazy loading gives you blank boxes. Addnetwork_idle: Trueand a smalldelay; nothing scrolls the page for you.- A non-matching
selectorfails the render withcapture_failed, it does not fall back to the viewport. widthwithoutheightis ignored. Pass both or neither.qualitydoes nothing for PNG. It is lossy-format only, sojpegandwebp.- Keep the key server-side. Anything embedded in a notebook you share, or in client code, is a leaked key — rotate it from the dashboard if that happens.