Capture a website screenshot with Python.

One call to requests.get, and the screenshot is in response.content, ready to write to a file or hand to the next step of your program.

Then what real code needs around it: errors and retries, a JSON body, a route in a FastAPI app, and a signed URL to put in a page.

What you need

  • Python 3.10 or newer, and requests: pip install requests.
  • An API key from the dashboard, in an environment variable rather than in the file.
  • A free account, if you do not have one: 100 screenshots a month, no card.

Create the key on the API keys page. It is shown once, at creation, so store it before closing the dialog, and keep it on the server: a key in browser or mobile code is readable by everyone who has the app.

The request

Save this as screenshot.py. It asks for one page at a 1280 × 800 viewport, at twice the pixel density, as WebP, and writes what comes back to a file:

screenshot.py
# screenshot.py: request one screenshot and save it.
import os

import requests

response = requests.get(
    "https://api.urlshot.io/v1/screenshot",
    params={
        "url": "https://en.wikipedia.org/wiki/Cartography",
        "format": "webp",
        "quality": 80,
        "viewport_width": 1280,
        "viewport_height": 800,
        "device_scale_factor": 2,
    },
    headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"},
    timeout=60,
)

# Anything that is not an image is the JSON error envelope.
if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']} (request {error['requestId']})")

with open("screenshot.webp", "wb") as file:
    file.write(response.content)
print("Wrote screenshot.webp")

Run it

Bash
export URLSHOT_API_KEY=sk_your_key_here
python screenshot.py

One render, one credit. Keep the timeout: without one, requests waits for ever on a connection that stops answering.

What comes back

On success, response.content is the image itself, as bytes: no JSON wrapper, no base64. This is the file that script writes:

The Wikipedia article on cartography, captured by the API at 1280 pixels wide
/v1/screenshot?url=https://en.wikipedia.org/wiki/Cartography&format=webp&quality=80&viewport_width=1280&viewport_height=800&device_scale_factor=2

Every response also carries headers that account for the call:

Response headers returned with a screenshot.
X-Request-IDOpaque identifier for this request. Quote it in any support conversation.
X-Urlshot-CacheHIT when the image was served from cache, MISS when it was rendered, BYPASS when cache_ttl was 0.
X-Urlshot-Credits-UsedRender credits consumed by this request. A cache hit consumes zero.
X-RateLimit-LimitRenders your plan may run at once.
X-Urlshot-RendererThe renderer build that produced the image, such as v4:4572cafff9ea; on a cache hit, the build that made the cached image. For support conversations, not for branching on.
X-RateLimit-RemainingRender slots still free after this request. Absent on a cache hit, where it is unknown rather than zero.
Cache-ControlHow long a browser may reuse the image: private, max-age=<seconds> when cache_ttl is set, and on a cache hit no longer than the cached copy has left; no-store when cache_ttl is 0.

Read them from response.headers, a dictionary that ignores the case of a name:

Python
# After the request: what it cost, and how to refer to it.
# response.headers ignores the case of a name.
credits = response.headers["X-Urlshot-Credits-Used"]  # "1", or "0" from cache
request_id = response.headers["X-Request-ID"]
print(f"request {request_id} used {credits} credit(s)")

Errors and retries

A failed call returns JSON instead of an image, with a stable code, a message, and the requestId to quote if you ask about it. The scripts here raise a RuntimeError with all three.

These codes are worth retrying, and each comes with a Retry-After header in seconds:

  • concurrency_limit_exceeded, 429: The workspace is already running the maximum number of concurrent renders.
  • rate_limit_exceeded, 429: The request rate limit for this API key has been exceeded.
  • service_unavailable, 503: The screenshot service is temporarily unavailable.

Every other code fails the same way however often you send it, and one that got as far as the browser has already cost a credit. So retry on Retry-After and nothing else:

retry.py
import os
import time

import requests


def screenshot(params, attempts=4):
    """Request a screenshot, retrying only when the API says how long to wait."""
    for attempt in range(1, attempts + 1):
        response = requests.get(
            "https://api.urlshot.io/v1/screenshot",
            params=params,
            headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"},
            timeout=60,
        )
        if response.ok:
            return response.content

        # Only a 429 or 503 carries Retry-After, in seconds. Anything else fails
        # the same way however often it is sent.
        retry_after = response.headers.get("Retry-After")
        error = response.json()["error"]
        if retry_after is None or attempt == attempts:
            raise RuntimeError(f"{error['code']}: {error['message']} (request {error['requestId']})")
        time.sleep(int(retry_after))


image = screenshot({
    "url": "https://en.wikipedia.org/wiki/Cartography",
    "format": "webp",
})
with open("screenshot.webp", "wb") as file:
    file.write(image)
print("Wrote screenshot.webp")

The retry setup often copied for requests, urllib3's Retry with a status_forcelist of 500, 502, 503 and 504, would also retry the 502 and 504 a broken page answers with — each one a charged render that will most likely fail again. Every code is in the error reference.

More options

Every option is a query parameter with a default; add it to params. These change a capture the most:

8 of the 17 options, with their ranges and defaults.
full_pageCapture the full scrollable page instead of only the viewport.boolean
formatOutput image format.png | jpeg | webp
qualityEncoder quality for jpeg and webp. Defaults to 80 for those formats and must be omitted for png.1–100
viewport_widthViewport width in CSS pixels.200–3840
device_scale_factorDevice pixel ratio to emulate.0.5–3
dark_modeEmulate prefers-color-scheme: dark before navigation.boolean
hide_selectorsCSS selector whose matching elements are hidden after navigation. Repeatable.string[]
block_cookie_bannersHide known consent dialogs and restore page scrolling before capturing. Available on every plan.boolean

A list in params is sent as the parameter repeated, which is how hide_selectors takes more than one selector:

options.py
import os

import requests

response = requests.get(
    "https://api.urlshot.io/v1/screenshot",
    # A list becomes the parameter repeated, once for each selector.
    params={
        "url": "https://en.wikipedia.org/wiki/Atlas",
        "full_page": "true",
        "format": "png",
        "hide_selectors": [".vector-header-container", ".mw-footer"],
    },
    headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"},
    timeout=60,
)

# Anything that is not an image is the JSON error envelope.
if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']} (request {error['requestId']})")

with open("atlas.png", "wb") as file:
    file.write(response.content)

That is the whole Atlas article as a PNG, without Wikipedia's header and footer. Booleans are written "true" to match the reference; Python's True is sent as True, which the API accepts as well. All 17 options are in the parameter reference.

POST with a JSON body

The same options can go in a JSON body, which is easier once you send CSS: nothing to encode. With json=, requests sets the content type and sends Python's True and lists as JSON's true and arrays — and a body needs those types: the string "true" is rejected there.

post.py
import os

import requests

response = requests.post(
    "https://api.urlshot.io/v1/screenshot",
    # json= sends Content-Type: application/json, and Python's True and lists
    # as JSON's true and arrays.
    json={
        "url": "https://en.wikipedia.org/wiki/Atlas",
        "full_page": True,
        "format": "png",
        "hide_selectors": [".vector-header-container", ".mw-footer"],
        "custom_css": "#siteNotice { display: none !important }",
    },
    headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"},
    timeout=60,
)

# Anything that is not an image is the JSON error envelope.
if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']} (request {error['requestId']})")

with open("atlas.png", "wb") as file:
    file.write(response.content)

Put every option in the body: a POST that also has query parameters is rejected. The rest of the rules are under POST with a JSON body.

In a FastAPI app

In a web app, call the API from your server and send the image on, so the key never reaches a browser. In FastAPI, use an async client: requests would block the event loop for the seconds a render takes, and every other request the server is handling would wait with it.

Bash
pip install fastapi uvicorn httpx

app.py
# app.py. Run with: uvicorn app:app
import logging
import os

import httpx
from fastapi import FastAPI, HTTPException, Response

# The pages this app shows previews of. Visitors pick a name, never an
# address: a route that took any URL would render any page on your credits.
PAGES = {
    "home": "https://example.com",
    "atlas": "https://en.wikipedia.org/wiki/Atlas",
}

app = FastAPI()

# One client for the whole app, so connections to the API are reused.
client = httpx.AsyncClient(
    headers={"Authorization": f"Bearer {os.environ['URLSHOT_API_KEY']}"},
    timeout=60,
)


@app.get("/previews/{page}")
async def preview(page: str) -> Response:
    url = PAGES.get(page)
    if url is None:
        raise HTTPException(status_code=404)

    response = await client.get(
        "https://api.urlshot.io/v1/screenshot",
        params={
            "url": url,
            "viewport_width": 1280,
            "format": "webp",
            "cache_ttl": 3600,
        },
    )
    if response.is_error:
        error = response.json()["error"]
        logging.error("%s: %s (request %s)", error["code"], error["message"], error["requestId"])
        raise HTTPException(status_code=502)

    return Response(
        content=response.content,
        media_type=response.headers["Content-Type"],
        headers={"Cache-Control": "public, max-age=3600"},
    )

  • Visitors choose a name, not an address. A route that rendered whatever URL it was given would let anyone spend your credits on pages of their own.
  • One client for the app. The AsyncClient keeps its connections to the API open between requests, rather than starting again for each one.
  • Repeat views are free. cache_ttl=3600 serves the same page from cache for an hour at no cost, the longest the free plan allows. See caching and credits.
  • A failure is logged, not passed on. The route logs the code and request id and answers 502.

Run uvicorn app:app and open /previews/home on port 8000, uvicorn's default.

Signed URLs

To put a capture straight into a page's <img>, sign the URL on your server instead. Anyone can load a signed URL, and changing any of its parameters breaks the signature:

sign.py
import hmac, os
from hashlib import sha256
from urllib.parse import urlencode


def signed_url(**options: str) -> str:
    query = urlencode({"key": os.environ["URLSHOT_KEY"], **options})

    signature = hmac.new(
        os.environ["URLSHOT_SIGNING_SECRET"].encode(),
        query.encode(),
        sha256,
    ).hexdigest()

    return f"https://api.urlshot.io/v1/screenshot?{query}&signature={signature}"


src = signed_url(url="https://example.com", viewport_width="1200")

URLSHOT_KEY is a publishable key (pk_…) and URLSHOT_SIGNING_SECRET its signing secret, both from the dashboard. The function signs the string urlencode returns and appends the signature to that same string, so what is signed is exactly what is sent. A signed URL does not expire, so add a cache_ttl to any you publish; the reference explains why under signed URLs.

Where to go next

  • The API reference — every parameter, response header and error code, generated from the contract the gateway validates against.
  • Screenshots for AI agents — giving a vision model the page as a browser draws it, layout and all.
  • Pricing — what a credit costs once the free allowance runs out.

The same examples in other languages.

Or see every code example.

Run it with your own key.

100 screenshots a month on the free plan. No card needed.