This page shows how to take website screenshots from Python with RenderScreenshot. It covers the synchronous SDK, concurrent captures with asyncio and httpx, a FastAPI endpoint, a Django template with a signed og:image, batches and pytest. Every listing is complete and ran as shown. For other languages, see the screenshot API code examples hub.
Last tested: 2026-10-18 with Python 3.15.0,
renderscreenshot1.0.2, httpx 0.28.1, FastAPI 0.146.0, Django 6.1.2, pytest 9.1.1 and pytest-asyncio 1.4.0. The code ran against a local stub of our API that returns the documented image, batch and error bodies, including a 429 withRetry-After. We did not call the live API for this page. The Django view ran throughRequestFactory, not a deployed project.
Install
The SDK is not on PyPI yet, so pip installs it from GitHub. Pin the tag so that a new commit on main cannot change your build:
pip install "renderscreenshot @ git+https://github.com/Render-Screenshot/[email protected]"
The same line works in requirements.txt. The SDK needs Python 3.9 or later and pulls in httpx. Put your key in the environment:
export RS_API_KEY=rs_live_xxxxx
Quick start
import os from pathlib import Path from renderscreenshot import Client, TakeOptions with Client(os.environ['RS_API_KEY']) as client: png = client.take( TakeOptions.url('https://example.com').preset('og_card').block_cookie_banners() ) Path('example-og.png').write_bytes(png) print(f'saved {len(png)} bytes')
take() returns bytes. The with block closes the client's connection pool when you are done. In a long-running process, create one client at import time and reuse it, because each Client keeps its own pool. The Python SDK reference lists every builder method; the og_card preset and the others are in the presets reference.
A shared client with a retry rule
The FastAPI view and the tests below import this module:
# shots.py import os import time from renderscreenshot import Client, RenderScreenshotError, TakeOptions client = Client(os.environ['RS_API_KEY'], timeout=60.0) def capture(url: str, attempts: int = 3) -> bytes: """Render an og_card PNG. Retries 429 and 5xx, waiting for Retry-After.""" for attempt in range(1, attempts + 1): try: return client.take(TakeOptions.url(url).preset('og_card')) except RenderScreenshotError as e: transient = e.http_status == 429 or e.http_status >= 500 if not transient or attempt == attempts: raise time.sleep(e.retry_after if e.retry_after is not None else 2 ** attempt) raise AssertionError('unreachable')
What the error object holds
RenderScreenshotError has http_status, code, message, retryable and retry_after. This is what the SDK raised for three stub responses:
| Stub response | http_status |
code |
retryable |
retry_after |
|---|---|---|---|---|
429 with Retry-After: 1 |
429 | too_many_requests |
False |
1 |
| 422 target timeout | 422 | timeout |
True |
None |
| 400 bad URL | 400 | invalid_url |
False |
None |
code is the API's own code, and the API reports a rate limit as too_many_requests. retryable, however, is checked against the SDK's shorter list (rate_limited, timeout, render_failed, internal_error). So a 429 comes back with retryable=False. That is why capture() looks at http_status. Each code has a page under the error codes index, and the retries guide explains when a 422 is worth a second try.
Many captures with asyncio and httpx
The SDK client is synchronous. In an asyncio program, call the HTTP API directly with httpx.AsyncClient. This is also the no-SDK fallback, and it needs only httpx:
import asyncio import os import httpx API = 'https://api.renderscreenshot.com' async def render(http: httpx.AsyncClient, gate: asyncio.Semaphore, url: str) -> bytes: body = {'url': url, 'preset': 'og_card', 'output': {'format': 'webp'}} for attempt in range(3): async with gate: r = await http.post('/v1/screenshot', json=body) if r.status_code == 429 or r.status_code >= 500: await asyncio.sleep(float(r.headers.get('retry-after', 2 ** attempt))) continue if r.is_error: err = r.json()['error'] raise RuntimeError(f"{url}: {err['code']} ({err['docs']})") return r.content raise RuntimeError(f'{url}: gave up after HTTP {r.status_code}') async def main(urls: list[str]) -> dict[str, bytes]: gate = asyncio.Semaphore(2) # stay under your plan's concurrency limit headers = {'Authorization': f"Bearer {os.environ['RS_API_KEY']}"} async with httpx.AsyncClient(base_url=API, headers=headers, timeout=90) as http: images = await asyncio.gather(*(render(http, gate, u) for u in urls)) return dict(zip(urls, images)) if __name__ == '__main__': pages = ['https://example.com/', 'https://example.com/about'] for url, img in asyncio.run(main(pages)).items(): print(url, len(img), 'bytes')
Three choices in this listing are deliberate:
- The semaphore wraps only the request. A task that is waiting out a
Retry-Aftergives its slot back, so the other tasks keep working. - The timeout is 90 seconds. The API's render timeout can go up to 60 seconds, and the client must wait longer than the server.
- The rate limit is per account. All your keys share one counter, so set the semaphore for the whole account, not per process. The limits per plan are in the rate limits reference.
If you prefer the SDK's builder, await asyncio.to_thread(client.take, options) works too. Each call then holds a thread for the length of the render.
FastAPI endpoint
import re from fastapi import FastAPI, HTTPException, Response from renderscreenshot import RenderScreenshotError from shots import capture app = FastAPI() SLUG = re.compile(r'[a-z0-9-]+') @app.get('/og/{slug}.png') def og_image(slug: str) -> Response: # plain def: FastAPI runs it in a worker thread if not SLUG.fullmatch(slug): raise HTTPException(status_code=404) try: png = capture(f'https://example.com/blog/{slug}') except RenderScreenshotError as e: raise HTTPException(status_code=503 if e.http_status == 429 else 502) from e return Response(png, media_type='image/png', headers={'Cache-Control': 'public, max-age=86400'})
The route is def, not async def, on purpose. capture() blocks, and an async def route would block the event loop for the whole render. The slug check stops a visitor from making your key capture any URL they like.
Django: a signed og:image in the template
A signed URL lets the crawler fetch the image from the API without your API key and without a request to your server. The signing happens in Python; only the public key ID and an HMAC signature appear in the HTML.
# blog/og.py import os from datetime import datetime, time, timedelta, timezone from renderscreenshot import Client, TakeOptions signer = Client( os.environ['RS_API_KEY'], signing_key=os.environ['RS_SIGNING_KEY'], # rs_secret_... public_key_id=os.environ['RS_PUBLIC_KEY_ID'], # rs_pub_... ) def og_image_url(page_url: str) -> str: # Midnight UTC, 8 days out: one stable URL per page per day, 7-8 days valid. day = datetime.now(timezone.utc).date() + timedelta(days=8) expires = datetime.combine(day, time.min, tzinfo=timezone.utc) return signer.generate_url(TakeOptions.url(page_url).preset('og_card'), expires)
# blog/views.py from django.shortcuts import render from .og import og_image_url def post_detail(request, slug): page_url = request.build_absolute_uri() return render(request, 'blog/post.html', {'og_image': og_image_url(page_url)})
<!-- templates/blog/post.html --> <meta property="og:image" content="{{ og_image }}"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630">
Django's autoescaping turns each & in the URL into &. That is correct HTML, and crawlers decode it back. Do not mark the URL |safe. Fixing the expiry to a day boundary matters: with now() + 7 days, the signature changes on every request, so every crawl sees a new image URL. Behind a proxy, set SECURE_PROXY_SSL_HEADER, or build_absolute_uri() returns an http:// page URL.
Batch
POST /v1/batch takes up to 10 URLs synchronously. Items fail one by one, not as a group:
batch = client.batch( ['https://example.com/', 'https://example.com/pricing', 'https://example.com/docs'], TakeOptions.url('').preset('og_card'), ) print(f"{batch['completed']}/{batch['total']} rendered") for item in batch['results']: if item['status'] == 'completed': print(item['url'], '->', item['image']['image_url']) else: print(item['url'], 'failed:', item['error'])
A failed item's error is a message string. Larger batches answer with processing; poll client.get_batch(batch_id) until it is done.
Test with pytest
Patch Client.take for code that uses the SDK. For the httpx code, give the client an httpx.MockTransport, which runs your handler instead of opening a socket:
import asyncio import json from unittest.mock import patch import httpx import pytest from fastapi.testclient import TestClient from renderscreenshot import Client, RenderScreenshotError import async_shots from fastapi_app import app from shots import capture def test_capture_retries_after_429(): rate_limited = RenderScreenshotError(429, 'too_many_requests', 'slow down', 0) with patch.object(Client, 'take', side_effect=[rate_limited, b'png']) as take: assert capture('https://example.com') == b'png' assert take.call_count == 2 def test_route_maps_render_failure_to_502(): failure = RenderScreenshotError(500, 'internal_error', 'boom', 0) with patch.object(Client, 'take', side_effect=failure): response = TestClient(app).get('/og/hello-world.png') assert response.status_code == 502 def test_route_rejects_odd_slugs(): assert TestClient(app).get('/og/Hello_World.png').status_code == 404 @pytest.mark.asyncio async def test_async_render_with_mock_transport(): def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == '/v1/screenshot' assert json.loads(request.content)['preset'] == 'og_card' return httpx.Response(200, content=b'webp-bytes') transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url='https://api.test') as http: image = await async_shots.render(http, asyncio.Semaphore(1), 'https://example.com') assert image == b'webp-bytes'
A retry_after of 0 keeps the retry test instant. The SDK testing guide adds contract tests against a fake server.
Source, changelog and next steps
- SDK source and issues: github.com/Render-Screenshot/rs-python. Release notes are in its CHANGELOG.
- Rendering an HTML string instead of a URL: HTML to image in Python.
- Not sure you need a web page capture at all: desktop vs web page screenshots in Python.
Get a free API key and run the quick start. The free plan gives you 150 credits every month.