Most Django projects that need screenshots start with Selenium, spend a week on ChromeDriver versions and headless flags in Docker, and end up with a worker that leaks memory. This guide skips that stage. The browser lives behind an API; Django does what it is good at: views, tasks, storage, caching.
Settings
# settings.py
import os
SNAPRENDER_API_KEY = os.environ["SNAPRENDER_API_KEY"]
SNAPRENDER_ENDPOINT = "https://app.snap-render.com/v1/screenshot"
The requests library is enough:
pip install requests
Screenshots as an API call
Send a GET request, get a PNG back. Ads and cookie banners blocked by default.
200 free renders a month. Paid plans start at $9.
A view that returns the image
# views.py
import requests
from django.http import HttpResponse, JsonResponse
from django.conf import settings
from django.views.decorators.http import require_GET
@require_GET
def screenshot(request):
url = request.GET.get("url", "")
if not url.startswith(("http://", "https://")):
return JsonResponse({"error": "url parameter required"}, status=400)
r = requests.get(
settings.SNAPRENDER_ENDPOINT,
params={
"url": url,
"format": "png",
"full_page": "true",
"block_cookie_banners": "true",
"cache_ttl": 86400,
},
headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
timeout=60,
)
if r.status_code != 200:
return JsonResponse({"error": "capture failed", "detail": r.text}, status=502)
response = HttpResponse(r.content, content_type="image/png")
response["Cache-Control"] = "public, max-age=86400"
return response
Wire it up in urls.py and /screenshot/?url=https://example.com returns a full-page PNG. The timeout=60 matters: a page that never settles should fail your request, not hold a worker forever.
Saving with the storage backend
For a model that stores a capture (a bookmark, a monitored page), write the bytes through default_storage so local disk, S3 and every other backend behave the same:
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
def save_capture(url: str, name: str) -> str:
r = requests.get(
settings.SNAPRENDER_ENDPOINT,
params={"url": url, "format": "jpeg", "full_page": "true"},
headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
timeout=60,
)
r.raise_for_status()
return default_storage.save(f"captures/{name}.jpeg", ContentFile(r.content))
Batches with Celery
Capturing 200 pages inside a request is a bad idea anywhere. A Celery task keeps the web workers free and gives you retries:
# tasks.py
from celery import shared_task
import requests
from django.conf import settings
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def capture_page(self, url: str, name: str):
try:
r = requests.get(
settings.SNAPRENDER_ENDPOINT,
params={"url": url, "format": "jpeg", "cache_ttl": 86400},
headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
timeout=60,
)
if r.status_code == 429:
raise self.retry(countdown=60)
r.raise_for_status()
except requests.RequestException as exc:
raise self.retry(exc=exc)
return default_storage.save(f"captures/{name}.jpeg", ContentFile(r.content))
A 429 means the per-minute burst or the monthly quota is exhausted; retrying after a minute handles bursts, and the monthly case is visible in the dashboard. For lists above a few hundred URLs, the API's batch endpoint accepts up to 50 URLs per call and posts a webhook when they finish, which replaces the task loop entirely.
Caching twice
cache_ttlon the API request: repeat captures of the same URL and options are served from the API's cache and do not count against your quota.- Django's cache for your own view: store the storage path or the bytes keyed by URL so your server does not repeat the HTTP round trip either.
from django.core.cache import cache
path = cache.get(f"shot:{url}")
if not path:
path = save_capture(url, slugify(url))
cache.set(f"shot:{url}", path, 86400)
PDF output
Set format to pdf for reports and archives. The result is a page image inside a PDF, not selectable text; for text PDFs generated from your own templates, send HTML to the API instead (see the HTML to PDF API page).
Related: Python quickstart, screenshot website with Python, batch screenshots.
Frequently asked questions
Do I need Selenium or Playwright installed on the Django server?
Not with this approach. The browser runs behind the screenshot API; Django only makes an HTTP request with the requests library and stores or returns the bytes.
Where should the API key live?
In an environment variable read in settings.py (SNAPRENDER_API_KEY), the same way you handle a database password. Never in a template or a JavaScript file.
How do I avoid capturing the same page over and over?
Pass cache_ttl to the API so repeat captures are served from its cache without counting against your quota, and cache the resulting file path in Django's cache framework for your own views.
Can this run in a background job?
Yes. The Celery example below captures a list of URLs one at a time and saves each file with Django's storage backend, so it works with local disk, S3 or anything else you have configured.