Python Screenshot API: SDK, asyncio, FastAPI and Django

Capture website screenshots from Python: the renderscreenshot SDK, concurrent asyncio calls with httpx, FastAPI and Django examples, and pytest mocks.

By Simon Chiu · Updated

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, renderscreenshot 1.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 with Retry-After. We did not call the live API for this page. The Django view ran through RequestFactory, 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-After gives 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 &amp;. 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

Get a free API key and run the quick start. The free plan gives you 150 credits every month.

Frequently asked questions

Is the RenderScreenshot Python SDK on PyPI?
Not yet. Install it from GitHub with pip, and pin the release tag in requirements.txt so builds stay reproducible until the PyPI release.
Does the Python SDK support asyncio?
The SDK client is synchronous and uses httpx under the hood. For many captures in an asyncio program, call the HTTP API with httpx.AsyncClient and a semaphore, or wrap SDK calls in asyncio.to_thread.
Should a FastAPI route that takes screenshots be async def?
Not when it calls the synchronous SDK. Declare it with plain def and FastAPI runs it in its worker thread pool, so a slow render does not block the event loop.
Why is e.retryable False on a 429?
The SDK stores the API's error code in e.code, and a rate limit arrives as too_many_requests, which is not in the SDK's retryable list. Retry on e.http_status == 429 and wait e.retry_after seconds.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.