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():
| Parameter | Type | Default | What it does |
|---|---|---|---|
url | str | required | Address of the page to capture |
format | str | png | Image output: png, jpeg, webp, avif, or pdf |
width | int | 1280 | Browser window width in pixels before the capture runs |
height | int | 720 | Browser window height in pixels before the capture runs |
full_page | bool | false | Scroll the entire document and stitch it into one tall image |
device | str | desktop | Viewport profile: desktop, mobile, or tablet |
retina | bool | false | Double the pixel density for Retina-quality output |
dark_mode | bool | false | Activate the page's dark color scheme via media query override |
selector | str | Isolate one element by its CSS selector and crop to its bounds | |
delay | int | 0 | Extra milliseconds to wait after load, handy for JS animations |
block_cookies | bool | false | Auto-dismiss cookie consent overlays before the capture |
block_ads | bool | false | Filter out ad placements and tracking banners |
block_chats | bool | false | Hide live-chat widgets like Intercom or Drift |
css | str | Your own stylesheet loaded into the page before the render starts | |
js | str | A 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 | |
|---|---|---|
| Installation | Chromium binary + matching driver, plus system fonts and shared memory config | pip install requests (likely already in your project) |
| Container size | Base image grows by 400-800 MB with Chrome and its dependencies | Your Docker image stays unchanged |
| Memory per capture | Chrome runs as a separate process inside each Gunicorn worker. Figure 300 MB or more per render on top of your existing footprint | Django's memory footprint stays at its baseline |
| Async support | Playwright has async API; Selenium blocks the calling thread | Works with httpx for native async in Django 4.1+ views |
| Celery integration | Chrome process per worker, OOM risk under load | One HTTP call per task, no browser process to manage |
| Cookie consent popups | Build dismiss handlers per site and rebuild them after every redesign | One query parameter: block_cookies=true |
| PaaS deploys (Railway, Render) | Needs a custom Docker image or buildpack for Chrome, deploy size balloons | Standard Python runtime, no extra buildpacks or Docker layers |
| Cost | Hosting bill grows linearly: each simultaneous render needs its own Chrome process | 200 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.