Automate website screenshots with Python for monitoring

Capture a page as a PNG with one request, write the bytes straight to a file, and branch on content-type instead of guessing when a capture fails. Tested August 2026.

A visual regression check needs to see whether a page changed. A status page needs a current thumbnail of each site it watches. A weekly report needs to show what a page actually looked like, not just link to it. All three need an image of a URL, not a description of one, and /v1/screenshot takes a URL and gives back a picture. The part that trips people up the first time is that the response is not JSON at all, which matters because every other FastAF endpoint is.

The response is bytes, not JSON

/v1/screenshot returns raw image data with a content-type of image/png. There is no envelope, no results key, nothing to call .json() on. Write the response body straight to a file:

import requests

API_KEY = "faf_live_..."  # your FastAF key

resp = requests.get(
    "https://api.fastaf.co/v1/screenshot",
    params={"url": "https://example.com"},
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=60,
)

if resp.headers.get("content-type", "").startswith("image/"):
    with open("screenshot.png", "wb") as f:
        f.write(resp.content)
else:
    print("capture failed:", resp.json()["error"])

That costs 5 credits. A default capture of https://example.com came back as a 1920x1080 PNG, 22,318 bytes. Call resp.json() out of habit and requests raises a decoding error, because it tries to parse the body as JSON text and a few thousand bytes of PNG binary is not valid JSON. resp.content is what you want, the raw bytes; resp.text would just hand you back the same bytes decoded as a garbled string, which is not what you want to write to a .png file.

Checking for failure the right way

A failed request does not come back as a broken image, it comes back as JSON with an error object. That is exactly why the code above checks content-type before doing anything else, instead of assuming success and finding out three functions later that screenshot.png is actually 40 bytes of JSON with a .png extension. Content-type is the one signal that reliably tells you which shape you got back, so branch on that first, every time, rather than trying to detect a bad image after the fact by, say, checking the byte count or opening the file with an image library and catching whatever exception that throws.

For a monitoring dashboard this matters more than it looks: the failure case is exactly the moment you most need a clean signal, since it usually means the target page is down or erroring, which is the thing you were trying to detect in the first place. A pipeline that silently saves a JSON error blob as if it were a PNG will show you a broken thumbnail with no explanation instead of the actual error message sitting right there in the response.

Sizing and format

Beyond url, the endpoint takes: fullPage (true to capture the entire scrollable page instead of one viewport), width and height to set the viewport before capture, wait_until (load, domcontentloaded, or networkidle, for pages that finish rendering after the initial load event), format (png, jpg, or webp), and quality (0 to 100, for the lossy formats). wait_until is the one that matters most for anything built with a modern frontend framework: a page can fire its load event before client-side JavaScript has finished painting the content you actually want in the picture, so a dashboard or single-page app is usually worth capturing with a later wait condition than the default, at the cost of a slightly slower request. None of these parameters change the fact that you get bytes back, only what ends up in them:

resp = requests.get(
    "https://api.fastaf.co/v1/screenshot",
    params={
        "url": "https://example.com",
        "fullPage": "true",
        "format": "jpg",
        "quality": 80,
    },
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=60,
)

For visual regression, keep width, height, and format fixed across runs. A diff between two captures is only meaningful if the two images were taken at the same size; changing the viewport between runs will show you a difference that has nothing to do with the page.

Building a monitoring loop

A thumbnail job for a status page or a report is a loop over URLs, saving each response under a name you can find later and logging whichever ones failed instead of dropping them silently:

import os

def capture_all(urls, out_dir="captures"):
    os.makedirs(out_dir, exist_ok=True)
    for i, url in enumerate(urls):
        resp = requests.get(
            "https://api.fastaf.co/v1/screenshot",
            params={"url": url, "width": 1280, "height": 800},
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=60,
        )
        if resp.headers.get("content-type", "").startswith("image/"):
            with open(f"{out_dir}/{i}.png", "wb") as f:
                f.write(resp.content)
        else:
            print(url, "failed:", resp.json()["error"])

In our measurements the median response was 3.9 seconds. That is fine for a handful of dashboard tiles on a schedule; for a few hundred pages you will want the requests running concurrently rather than one after another, since a sequential pass over 500 URLs at that median would take roughly half an hour on its own.

What this costs

Volume Credits (30 days) Pack that covers it Cost
100 pages, checked daily 15,000 Growth, $29 ~$5.80 of the pack
500 pages, checked daily 75,000 Growth, $29 the whole pack
500 pages, checked every 6 hours 300,000 Scale, $99 the whole pack

Failed requests are never billed: credits are reserved when a request starts and returned if it does not come back with a result, so a broken target does not quietly drain your balance. The free tier is 500 credits with no card, 100 screenshots to check timing and output size against your own pages before you build a schedule around it.