Skip to main content
Ssnap Docs
Guides

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

capture.py
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:

capture.py
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:

stream.py
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:

batch.py
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.

async_capture.py
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.

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_page plus lazy loading gives you blank boxes. Add network_idle: True and a small delay; nothing scrolls the page for you.
  • A non-matching selector fails the render with capture_failed, it does not fall back to the viewport.
  • width without height is ignored. Pass both or neither.
  • quality does nothing for PNG. It is lossy-format only, so jpeg and webp.
  • 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.

Next