# Screenshot API in Python: requests, httpx, webhooks, signed URLs

> Website screenshots from Python with requests or async httpx: full page, mobile presets, elements, cookie banners, polling, webhooks, signed URLs.

Published 2026-11-04 · https://shareglint.com/en/screenshot-api/python

To take a website screenshot in Python, `POST` JSON such as `{"url": "https://example.com", "full_page": true}` to `https://shareglint.com/v1/screenshots?wait=true` with `requests`, an `Authorization: Bearer sk_live_…` header and a timeout. The response is JSON whose `url` is the finished PNG, JPEG, WebP or PDF. For many pages, use `httpx.AsyncClient` or queue the captures and take a webhook; for an `<img>` in a template, sign a `/s/` URL instead of exposing the key.

No browser to install, no package of ours to install: `requests` (or `httpx`) and the standard library. Every snippet below was run against the API with Python 3.12, requests 2.34 and httpx 0.28.

## One screenshot with requests

```python
import os
import requests

API = "https://shareglint.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SHOT_API_KEY']}"

def screenshot(**options) -> dict:
    r = session.post(f"{API}/screenshots", params={"wait": "true"}, json=options, timeout=(5, 60))
    body = r.json()
    if r.status_code == 503 and "id" in body:  # sync window ran out: poll
        return wait_for(body["id"])
    if r.status_code == 500:  # the page failed; quota returned
        raise RuntimeError(f"capture failed: {body['error']}")
    r.raise_for_status()
    return body

shot = screenshot(url="https://example.com", viewport_width=1200, viewport_height=630)
with open("example.png", "wb") as f:
    f.write(requests.get(shot["url"], timeout=30).content)
```

Two details that bite in Python. `requests` waits forever without `timeout` — the [requests docs](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts) say so explicitly — and `(5, 60)` means 5 s to connect, 60 s to read, comfortably longer than our sync window. And download the file with plain `requests.get`, not the session: the session would send your API key to the file host.

The default viewport is 1280×800. Change the format with `format="jpeg", quality=80`, `"webp"` or `"pdf"`.

## Full page, devices, elements, waiting

Keyword arguments map one to one to the JSON fields:

```python
# The whole page; lazy_load scrolls down first so lazy images load
screenshot(url="https://example.com/blog", full_page=True, lazy_load=True)

# A phone: Pixel 8 viewport, pixel ratio 2.625, touch, mobile user agent
screenshot(url="https://example.com", device="pixel-8")

# One element at retina resolution
screenshot(url="https://example.com/pricing", selector="#plans", scale=2)

# A clean capture: no consent banner, no ads, no chat bubble, data loaded
screenshot(
    url="https://example.com/dashboard",
    block_cookie_banners=True,
    block_ads=True,
    hide_selectors=["#intercom-container", ".promo-bar"],
    wait_for=".chart svg",
    delay=1,
    click_selector="#tab-yearly",
)
```

Presets include `iphone-15`, `iphone-se`, `pixel-8`, `galaxy-s24`, `ipad`, `macbook` and `desktop-1920` ([full list in the docs](https://shareglint.com/en/docs#screenshots)). `block_cookie_banners` clicks "accept" on OneTrust, Cookiebot, Didomi, Usercentrics and similar dialogs, then hides leftovers with the EasyList Cookie List. A `selector` or `click_selector` that matches nothing fails the capture with a clear error and returns the quota.

## Many screenshots: async httpx

Sync requests are simplest for one capture. For a list of URLs, run them concurrently, but cap concurrency — five hundred open connections help nobody:

```python
import asyncio
import httpx

async def capture_all(urls: list[str]) -> list[dict]:
    limit = asyncio.Semaphore(5)
    async with httpx.AsyncClient(
        base_url=API,
        headers={"Authorization": f"Bearer {os.environ['SHOT_API_KEY']}"},
        timeout=httpx.Timeout(60, connect=5),
    ) as client:
        async def one(url: str) -> dict:
            async with limit:
                r = await client.post("/screenshots", params={"wait": "true"},
                                      json={"url": url, "block_cookie_banners": True})
                return r.json()
        return await asyncio.gather(*(one(u) for u in urls))

results = asyncio.run(capture_all(["https://example.com", "https://example.org"]))
```

httpx's default timeout is 5 seconds ([httpx timeouts](https://www.python-httpx.org/advanced/timeouts/)), shorter than a real page takes to load and capture, so set it explicitly as above. Each result is either a completed screenshot or an error body — check `status` before using `url`.

## Queue, then poll or take a webhook

Without `?wait=true` the API answers 202 at once. Poll the status endpoint:

```python
import time

def queue(**options) -> str:
    r = session.post(f"{API}/screenshots", json=options, timeout=(5, 30))
    r.raise_for_status()
    return r.json()["id"]  # r.json()["url"] is the status endpoint, not the file

def wait_for(shot_id: str, every: float = 2, tries: int = 30) -> dict:
    for _ in range(tries):
        shot = session.get(f"{API}/screenshots/{shot_id}", timeout=(5, 30)).json()
        if shot["status"] == "completed":
            return shot
        if shot["status"] == "failed":
            raise RuntimeError(shot["error"])
        time.sleep(every)
    raise TimeoutError(f"screenshot {shot_id} still running")

full = wait_for(queue(url="https://example.com", device="pixel-8", full_page=True, lazy_load=True))
```

In a web app, pass `webhook_url` instead and let us call you with `screenshot.completed` or `screenshot.failed`. Verify every call: `X-Webhook-Signature` is the hex HMAC-SHA256 of `X-Webhook-Timestamp + "." + raw body`, keyed with your webhook secret (dashboard or `GET /v1/account`). The `str | None` hints need Python 3.10+.

```python
import hashlib
import hmac
from flask import Flask, abort, request

def verify_webhook(raw_body: bytes, timestamp: str | None, signature: str | None, secret: str) -> bool:
    if not timestamp or not signature:
        return False
    if abs(time.time() - int(timestamp)) > 300:  # replay window
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

app = Flask(__name__)

@app.post("/webhooks/screenshots")
def screenshot_webhook():
    ok = verify_webhook(
        request.get_data(),  # raw bytes: not request.json re-serialized
        request.headers.get("X-Webhook-Timestamp"),
        request.headers.get("X-Webhook-Signature"),
        os.environ["SHOT_WEBHOOK_SECRET"],
    )
    if not ok:
        abort(400)
    event = request.get_json()
    if event["event"] == "screenshot.completed":
        ...  # event["id"], event["url"]
    return "", 204
```

In Django the raw bytes are `request.body` (mark the view `@csrf_exempt`). [`hmac.compare_digest`](https://docs.python.org/3/library/hmac.html#hmac.compare_digest) compares in constant time; `==` leaks timing. We retry failed deliveries five times with backoff, so return 2xx fast and hand the work to Celery or RQ.

## A signed /s/ URL for templates

A Django or Jinja template that shows a screenshot should not call the API on every page view. Sign a URL once with your signing secret and account `id`, and print it as `<img src>` or `og:image`:

```python
import hashlib
import hmac
import os
from urllib.parse import quote

def signed_screenshot_url(page_url: str, ext: str = "png", **options) -> str:
    params = {"url": page_url}
    for name, value in options.items():
        if isinstance(value, bool):
            value = "true" if value else "false"  # str(True) is "True": rejected
        elif isinstance(value, (list, tuple)):
            value = ",".join(value)
        params[name] = str(value)
    path = f"/s/{os.environ['SHOT_ACCOUNT_ID']}.{ext}"
    query = "&".join(
        f"{quote(name, safe='')}={quote(value, safe='')}"
        for name, value in sorted(params.items())
    )
    secret = os.environ["SHOT_SIGNING_SECRET"].encode()
    s = hmac.new(secret, f"{path}?{query}".encode(), hashlib.sha256).hexdigest()[:32]
    return f"https://shareglint.com{path}?{query}&s={s}"

signed_screenshot_url("https://example.com", device="mobile", block_cookie_banners=True)
```

The canonical string is the path, `?` and every parameter but `s`, sorted by name, each name and value percent-encoded per RFC 3986. That is why the code uses `quote(..., safe="")` and not `urlencode`, which writes spaces as `+`. We verified this function against the server: a correct URL redirects to the file, a changed parameter is a 403. The first request captures (and charges); repeats within 24 hours redirect to the cached file.

## Errors and quota

- **402 `quota_exceeded`** — no renders left; nothing was charged.
- **422** — invalid option or non-public `url`; the body has `message` and per-field `errors`. Do not retry.
- **429** — rate limited; sleep for `Retry-After`.
- **500 with `?wait=true`** — the page failed (HTTP error, timeout, missing element); `error` says why and the quota came back.
- **503 `sync_overloaded`** — still running; poll the `id`.

A viewport or element capture uses 1 render, a full page 2. When you retry a `POST` after a dropped connection, send the same `Idempotency-Key` header so a second attempt returns the first result instead of a second capture. Plans are on the [pricing page](https://shareglint.com/en/pricing); if you prefer running Chromium yourself, the [Playwright guide](https://shareglint.com/en/guides/playwright-screenshot) covers `page.screenshot` in Python.

## FAQ

### How do I take a website screenshot in Python without Selenium or Playwright?

Send a POST to the /v1/screenshots endpoint with requests: requests.post(url, params={"wait": "true"}, json={"url": "https://example.com"}, headers={"Authorization": "Bearer sk_live_..."}, timeout=(5, 60)). The JSON response has a url to the finished image, which you download with requests.get.

### Can I take many screenshots in parallel from Python?

Yes. Use httpx.AsyncClient with asyncio.gather and an asyncio.Semaphore that caps how many requests are open at once, or queue the screenshots without ?wait=true and collect them by webhook.

### Why is my signed screenshot URL rejected with 403 in Python?

Usually because urllib.parse.urlencode encodes a space as + while the signature is computed over RFC 3986 encoding (%20). Encode each name and value with urllib.parse.quote(value, safe='') when you build the string you sign.

### What does a screenshot cost?

A viewport or element screenshot uses 1 render of your quota and a full-page one 2. Repeating the same URL with the same options within 24 hours is free, and failed pages give the render back.
