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
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 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:
# 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). 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:
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), 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:
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+.
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 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:
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 hasmessageand per-fielderrors. Do not retry. - 429 — rate limited; sleep for
Retry-After. - 500 with
?wait=true— the page failed (HTTP error, timeout, missing element);errorsays why and the quota came back. - 503
sync_overloaded— still running; poll theid.
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; if you prefer running Chromium yourself, the Playwright guide covers page.screenshot in Python.