Django Screenshot API Integration — requests, httpx, Celery, DRF

Capture webpage screenshots from Django with requests.get() or async httpx. Celery tasks with retries, management commands, DRF endpoints. No Selenium or Playwright binary needed.

Vitalii Holben Updated

ScreenshotRun's screenshot API turns any URL into a PNG, JPEG, WebP, AVIF, or PDF through a plain HTTP call from your Django project. Use requests for synchronous views, httpx for async ones. No headless browser running alongside Gunicorn, no Selenium grid eating RAM on your server.

The usual Django approach to screenshots involves Selenium WebDriver or Playwright. Each one installs Chromium on the machine, which pulls in hundreds of megabytes of dependencies and a matching driver binary. On a containerized deploy the Chrome layer alone doubles the image size. If the goal is simply converting URLs to images for thumbnails or monitoring, a REST call through requests.get() replaces the entire browser stack.

Your first Django screenshot API call

Register through the dashboard. Once your email is verified, the API key shows up on your account page. Every plan, including the free one at 200 captures per month, includes the full parameter set.

Install the requests library if your project does not have it yet:

pip install requests

Then test it in a Django shell or a standalone script:

import requests

api_key = "YOUR_API_KEY"

response = requests.get(
    "https://api.screenshotrun.com/v1/screenshots/capture",
    params={
        "url": "https://github.com",
        "format": "png",
        "response_type": "image",
    },
    headers={"Authorization": f"Bearer {api_key}"},
    timeout=60,
)
response.raise_for_status()

with open("github.png", "wb") as f:
    f.write(response.content)

print(f"Saved: github.png ({len(response.content) // 1024} KB)")

The requests library handles redirects and connection pooling behind the scenes. Gzip decompression happens automatically too. Our servers render the page in headless Chrome, wait for JavaScript to settle and images to load, then stream the result back as raw bytes.

Store the API key in Django settings

Keep secrets out of your codebase. Add the key to your environment and read it in settings.py:

import os

SCREENSHOTRUN_KEY = os.environ.get("SCREENSHOTRUN_KEY", "")

If your project uses django-environ:

import environ

env = environ.Env()

SCREENSHOTRUN_KEY = env("SCREENSHOTRUN_KEY")

Either way, the key lives in your .env file or hosting platform's environment variables. Reference it anywhere with settings.SCREENSHOTRUN_KEY.

Reusable Django screenshot service

A small module that wraps the API and keeps your views clean:

import requests
from django.conf import settings


def capture_screenshot(
    url: str,
    format: str = "png",
    width: int = 1280,
    full_page: bool = False,
) -> bytes:
    response = requests.get(
        "https://api.screenshotrun.com/v1/screenshots/capture",
        params={
            "url": url,
            "format": format,
            "width": width,
            "full_page": str(full_page).lower(),
            "response_type": "image",
        },
        headers={"Authorization": f"Bearer {settings.SCREENSHOTRUN_KEY}"},
        timeout=60,
    )
    response.raise_for_status()
    return response.content

Call it from a view or a Celery task. Works in management commands too:

from django.core.files.base import ContentFile
from django.core.files.storage import default_storage

image_bytes = capture_screenshot(
    url="https://github.com",
    format="webp",
    width=1920,
    full_page=True,
)

path = default_storage.save("screenshots/github-full.webp", ContentFile(image_bytes))
print(f"Stored at: {path}")

Django's default_storage backend writes to local disk, S3, GCS, or whatever you configured in STORAGES. The screenshot bytes flow through the same pipeline as any uploaded file.

Django view that returns a screenshot

Expose an endpoint that your frontend can call. The Django view adds credentials, calls the API, then streams the image to the browser:

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.http import require_GET
from django.contrib.auth.decorators import login_required


@require_GET
@login_required
def screenshot_view(request):
    target_url = request.GET.get("url")
    if not target_url:
        return HttpResponseBadRequest("Missing url parameter")

    fmt = request.GET.get("format", "png")
    width = int(request.GET.get("width", 1280))

    image_bytes = capture_screenshot(
        url=target_url,
        format=fmt,
        width=width,
    )

    content_types = {
        "png": "image/png",
        "jpeg": "image/jpeg",
        "webp": "image/webp",
    }

    response = HttpResponse(image_bytes, content_type=content_types.get(fmt, "image/png"))
    response["Cache-Control"] = "public, max-age=3600"
    return response

Wire it into your urls.py:

from django.urls import path
from .views import screenshot_view

urlpatterns = [
    path("api/screenshot/", screenshot_view, name="screenshot"),
]

The @login_required decorator keeps the endpoint private. Only authenticated users reach it. The one-hour Cache-Control header tells browsers to reuse the image instead of requesting it again for the same URL.

Async screenshots with httpx

Django 4.1+ supports async views natively. Pair them with httpx to avoid blocking the event loop while the API renders your page:

pip install httpx
import httpx
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.http import require_GET


async def capture_async(
    url: str,
    format: str = "png",
    width: int = 1280,
) -> bytes:
    async with httpx.AsyncClient(timeout=60) as client:
        response = await client.get(
            "https://api.screenshotrun.com/v1/screenshots/capture",
            params={
                "url": url,
                "format": format,
                "width": width,
                "response_type": "image",
            },
            headers={"Authorization": f"Bearer {settings.SCREENSHOTRUN_KEY}"},
        )
        response.raise_for_status()
        return response.content


@require_GET
async def async_screenshot_view(request):
    target_url = request.GET.get("url")
    if not target_url:
        return HttpResponse("Missing url parameter", status=400)

    fmt = request.GET.get("format", "png")
    image_bytes = await capture_async(target_url, format=fmt)

    return HttpResponse(image_bytes, content_type=f"image/{fmt}")

Under ASGI (Uvicorn or Hypercorn), this view handles multiple concurrent screenshot requests without spawning extra threads. Each await yields the worker while waiting for the API response. Under WSGI the view still works. Django wraps it in a synchronous context automatically.

Background screenshots with Celery

Screenshot captures average 2-4 seconds. In a web request that stalls the page load. Offload the work to Celery and let the user continue browsing:

from celery import shared_task
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage


@shared_task(bind=True, max_retries=3, default_retry_delay=10)
def capture_screenshot_task(self, url: str, storage_path: str, format: str = "png"):
    from yourapp.screenshots import capture_screenshot  # import from your module

    try:
        image_bytes = capture_screenshot(url=url, format=format)
        default_storage.save(storage_path, ContentFile(image_bytes))
    except Exception as exc:
        raise self.retry(exc=exc)

Dispatch it from a view or a signal handler:

capture_screenshot_task.delay(
    url="https://example.com",
    storage_path="screenshots/example.png",
)

# Batch capture:
urls = ["https://github.com", "https://djangoproject.com", "https://pypi.org"]

for url in urls:
    slug = url.split("//")[1].replace("/", "_").rstrip("_")
    capture_screenshot_task.delay(url, f"screenshots/{slug}.png")

Celery's max_retries and default_retry_delay handle transient failures. If the API returns a 5xx or the network drops, the task retries up to three times with 10-second pauses. You do not need to write retry loops yourself because Celery already provides them.

Management command for bulk captures

A Django management command that captures a list of URLs from the command line. Useful for one-off batch jobs or cron-triggered monitoring runs:

from django.core.management.base import BaseCommand
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage


class Command(BaseCommand):
    help = "Capture screenshots of one or more URLs"

    def add_arguments(self, parser):
        parser.add_argument("urls", nargs="+", type=str)
        parser.add_argument("--format", default="png", choices=["png", "jpeg", "webp"])
        parser.add_argument("--width", type=int, default=1280)

    def handle(self, *args, **options):
        for url in options["urls"]:
            slug = url.split("//")[1].replace("/", "_").rstrip("_")[:50]
            filename = f"screenshots/{slug}.{options['format']}"

            self.stdout.write(f"Capturing {url}...")

            image_bytes = capture_screenshot(
                url=url,
                format=options["format"],
                width=options["width"],
            )

            default_storage.save(filename, ContentFile(image_bytes))
            self.stdout.write(self.style.SUCCESS(f"  Saved: {filename} ({len(image_bytes) // 1024} KB)"))

Run it with:

python manage.py capture_screenshots https://github.com https://djangoproject.com --format webp --width 1920

The command accepts multiple URLs as positional arguments. Add it to a crontab or a CI/CD step for scheduled monitoring. Because it uses default_storage, the screenshots land wherever your Django storage backend points: local disk during development, S3 in production.

Django REST Framework endpoint

If your project uses DRF, a serializer-based endpoint gives you automatic validation and throttling:

from rest_framework import serializers, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.http import HttpResponse as DjangoHttpResponse


class ScreenshotRequestSerializer(serializers.Serializer):
    url = serializers.URLField()
    format = serializers.ChoiceField(choices=["png", "jpeg", "webp"], default="png")
    width = serializers.IntegerField(min_value=320, max_value=3840, default=1280)


@api_view(["GET"])
@permission_classes([IsAuthenticated])
def drf_screenshot_view(request):
    serializer = ScreenshotRequestSerializer(data=request.query_params)
    serializer.is_valid(raise_exception=True)

    data = serializer.validated_data
    image_bytes = capture_screenshot(
        url=data["url"],
        format=data["format"],
        width=data["width"],
    )

    return DjangoHttpResponse(
        image_bytes,
        content_type=f"image/{data['format']}",
        headers={"Cache-Control": "public, max-age=3600"},
    )

DRF's IsAuthenticated permission class restricts access to token-bearing users. The serializer validates the URL format and restricts output to three image types. Width gets clamped between 320 and 3840 pixels. Anything invalid? A 400 with field-level error messages, before the API call even fires.

Screenshot API parameters for Django

Each parameter maps to a key in the params dict you pass to requests.get() or httpx.get():

ParameterTypeDefaultWhat it does
urlstrrequiredAddress of the page to capture
formatstrpngImage output: png, jpeg, webp, avif, or pdf
widthint1280Browser window width in pixels before the capture runs
heightint720Browser window height in pixels before the capture runs
full_pageboolfalseScroll the entire document and stitch it into one tall image
devicestrdesktopViewport profile: desktop, mobile, or tablet
retinaboolfalseDouble the pixel density for Retina-quality output
dark_modeboolfalseActivate the page's dark color scheme via media query override
selectorstrIsolate one element by its CSS selector and crop to its bounds
delayint0Extra milliseconds to wait after load, handy for JS animations
block_cookiesboolfalseAuto-dismiss cookie consent overlays before the capture
block_adsboolfalseFilter out ad placements and tracking banners
block_chatsboolfalseHide live-chat widgets like Intercom or Drift
cssstrYour own stylesheet loaded into the page before the render starts
jsstrA script that runs in the page context once the DOM is ready

PDF-specific options, webhook callbacks, geolocation, proxy routing. The API documentation covers the full set.

Dark mode, element capture, custom CSS

Dark mode

Pass "dark_mode": "true" in the params dict. The renderer tells Chrome to report prefers-color-scheme: dark before navigating to the target URL. Pages that ship a dark stylesheet (GitHub, Django docs among others) switch to it automatically:

image_bytes = capture_screenshot(
    url="https://github.com",
    format="png",
)
# Add dark_mode to the params inside capture_screenshot,
# or call the API directly:
response = requests.get(
    "https://api.screenshotrun.com/v1/screenshots/capture",
    params={
        "url": "https://github.com",
        "dark_mode": "true",
        "response_type": "image",
    },
    headers={"Authorization": f"Bearer {settings.SCREENSHOTRUN_KEY}"},
    timeout=60,
)

Element capture

The selector parameter narrows the capture to one DOM element. Point it at a CSS selector like .repo-list or #pricing, and the returned image shows only that element. Everything else on the page is discarded:

"selector": ".repo-list"

Custom CSS

The css parameter injects your rules into the page before Chrome renders it. Common use in Django projects: hiding sticky headers that repeat in every full-page capture. You can also collapse promotional banners or force a white background for consistent thumbnail grids:

"css": "header.sticky { display: none !important; } .promo { height: 0; overflow: hidden; }"

Pass dark mode, a selector, and a CSS override in the same params dict. One HTTP call does the job. The API applies your stylesheet and enables the dark palette. It then locates the target element and ships back a single cropped image.

Retry logic for production Django apps

The requests library raises ConnectionError on network failures but treats HTTP 429 or 503 as successful responses you need to inspect yourself. A retry wrapper keeps transient problems from surfacing in your views:

import time
import requests
from django.conf import settings


def capture_with_retry(url: str, max_retries: int = 2) -> bytes:
    for attempt in range(max_retries + 1):
        response = requests.get(
            "https://api.screenshotrun.com/v1/screenshots/capture",
            params={
                "url": url,
                "format": "png",
                "response_type": "image",
            },
            headers={"Authorization": f"Bearer {settings.SCREENSHOTRUN_KEY}"},
            timeout=60,
        )

        if response.ok:
            return response.content

        if response.status_code == 429:
            wait = int(response.headers.get("Retry-After", 5))
            time.sleep(wait)
            continue

        if response.status_code >= 500 and attempt < max_retries:
            time.sleep((attempt + 1) * 2)
            continue

        response.raise_for_status()

    raise requests.HTTPError("Retries exhausted")

A 429 response tells you the plan's per-second or per-minute cap was reached. The wrapper checks the Retry-After header for the exact cooldown period and pauses before retrying. A 5xx points to a temporary issue on the rendering cluster, so each retry backs off a bit longer. If the server returns a 401 or 422, the function raises right away. Retrying won't help. A wrong API key or a missing url parameter needs a code fix, not another attempt.

Screenshot API vs. Selenium and Playwright in Django

Selenium and Playwright are the standard tools when Django tests need a real browser to click buttons and fill forms. For the narrower task of converting a URL into an image file, they bring infrastructure overhead that a REST call avoids.

Self-hosted (Selenium / Playwright)ScreenshotRun API
InstallationChromium binary + matching driver, plus system fonts and shared memory configpip install requests (likely already in your project)
Container sizeBase image grows by 400-800 MB with Chrome and its dependenciesYour Docker image stays unchanged
Memory per captureChrome runs as a separate process inside each Gunicorn worker. Figure 300 MB or more per render on top of your existing footprintDjango's memory footprint stays at its baseline
Async supportPlaywright has async API; Selenium blocks the calling threadWorks with httpx for native async in Django 4.1+ views
Celery integrationChrome process per worker, OOM risk under loadOne HTTP call per task, no browser process to manage
Cookie consent popupsBuild dismiss handlers per site and rebuild them after every redesignOne query parameter: block_cookies=true
PaaS deploys (Railway, Render)Needs a custom Docker image or buildpack for Chrome, deploy size balloonsStandard Python runtime, no extra buildpacks or Docker layers
CostHosting bill grows linearly: each simultaneous render needs its own Chrome process200 captures free each month, then pay per capture

Selenium and Playwright still earn their place in your CI test matrix and any flow that logs into a protected page. But for the bread-and-butter use case (feed in a URL, get back an image) swapping the browser stack for a four-line requests.get() call drops hundreds of megabytes off your Docker image and removes a class of ops headaches: driver mismatches and zombie Chrome processes. Shared-memory limits stop being your problem too.

This fits naturally into Django workflows. Capture competitor homepages on a nightly Celery beat schedule and store the diffs in S3. Render Metabase or Grafana panels as images for weekly Slack digests. Or let a management command seed your thumbnail cache after every deploy.

FAQ#

Use requests for synchronous Django views and Celery tasks. Use httpx if you run async views under ASGI (Uvicorn, Daphne). Both work identically with the API. The examples on this page show both approaches.

No. ScreenshotRun renders pages on its own Chrome cluster. Your Django app sends an HTTP GET with requests or httpx and receives image bytes. No browser binary, no WebDriver, no system font packages to install.

Use a Celery task with @shared_task. Set max_retries and default_retry_delay on the task decorator to handle transient failures. Dispatch the task from your view with .delay() and the screenshot runs outside the request cycle.

Yes. Create a serializer that validates the URL and format parameters, then return the image bytes in a DjangoHttpResponse. DRF handles authentication, throttling, and input validation. The full DRF example is on this page.

It does. Django detects that the view is async and wraps it in a synchronous context when running under WSGI (Gunicorn). You lose the concurrency benefit, but the code still runs correctly. For true async, deploy with Uvicorn or Daphne.

PNG, JPEG, WebP, AVIF, and PDF. Set the format key in the params dict. The response body contains raw image bytes that you can save through Django default_storage, return in an HttpResponse, or attach to an email.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call