Django Screenshot API: Celery, ImageField and PDF Views

Website screenshots and PDFs in Django 6: a Celery task that honours Retry-After, an ImageField save, a management command, a signed og:image template tag.

By Simon Chiu · Updated

This page wires website screenshots and PDFs into a Django project. A Celery task stores each capture on an ImageField. A management command backfills old rows, and a template tag puts a signed og:image in the page head. A view turns a template into a PDF, and Django's test runner covers it all without network calls. Many older Django screenshot tutorials use PhantomJS or wkhtmltoimage, which are no longer developed, or start Selenium inside a web worker. Here, no browser runs on your servers. The SDK basics, asyncio and FastAPI are on the Python screenshot API page.

Last tested: 2026-10-18 with Python 3.15.0, Django 6.1.2, Celery 5.6.3, Pillow 13.0.0 and renderscreenshot 1.0.2. manage.py test passed 5 tests. capture_previews --now ran against a local stub that returns the documented image and error responses, including a 429 with Retry-After. We did not call the live API or run a Celery worker against Redis. The tasks ran with Celery's apply().

Install

The SDK is not on PyPI yet. Install it from GitHub and pin the tag:

pip install django celery pillow \
  "renderscreenshot @ git+https://github.com/Render-Screenshot/[email protected]"

Pillow is there for ImageField. Put the keys in settings from the environment:

# mysite/settings.py
RENDERSCREENSHOT = {
    'API_KEY': os.environ.get('RS_API_KEY', ''),          # rs_live_...
    'SECRET_KEY': os.environ.get('RS_SECRET_KEY'),        # rs_secret_..., signs og:image URLs
    'PUBLIC_KEY_ID': os.environ.get('RS_PUBLIC_KEY_ID'),  # rs_pub_..., appears in the HTML
}
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_ROUTES = {'links.tasks.capture_preview': {'queue': 'screenshots'}}

Create one client per process. functools.cache gives each Celery worker process and each Gunicorn worker its own client and connection pool:

# links/screenshots.py
from functools import cache

from django.conf import settings
from renderscreenshot import Client


@cache
def get_client() -> Client:
    conf = settings.RENDERSCREENSHOT
    return Client(
        conf['API_KEY'],
        timeout=90.0,  # longer than the API's 60-second maximum wait
        signing_key=conf.get('SECRET_KEY'),
        public_key_id=conf.get('PUBLIC_KEY_ID'),
    )

The model and the Celery task

# links/models.py
from django.db import models


class Link(models.Model):
    url = models.URLField()
    preview = models.ImageField(upload_to='previews/%Y/%m/', blank=True)
    captured_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
# links/tasks.py
import logging

from celery import shared_task
from django.core.files.base import ContentFile
from django.utils import timezone
from renderscreenshot import RenderScreenshotError, TakeOptions

from .models import Link
from .screenshots import get_client

log = logging.getLogger(__name__)

# 422 target errors that can pass on a second try.
TRANSIENT_TARGET = {'timeout', 'navigation_failed', 'screenshot_failed'}


@shared_task(bind=True, max_retries=5)
def capture_preview(self, link_id: int) -> str:
    link = Link.objects.filter(pk=link_id).first()
    if link is None:
        return 'deleted'

    options = TakeOptions.url(link.url).preset('og_card').format('webp').block_cookie_banners()
    try:
        image = get_client().take(options)
    except RenderScreenshotError as e:
        if e.http_status == 429:
            raise self.retry(exc=e, countdown=e.retry_after or 30)
        if e.http_status >= 500 or (e.http_status == 422 and e.code in TRANSIENT_TARGET):
            raise self.retry(exc=e, countdown=60 * 2 ** self.request.retries, max_retries=3)
        log.warning('capture failed for link %s: %s %s (%s)', link_id, e.http_status, e.code, e.request_id)
        raise  # 400, 401, 403: a retry cannot fix these

    link.preview.save(f'link-{link.pk}.webp', ContentFile(image), save=False)
    link.captured_at = timezone.now()
    link.save(update_fields=['preview', 'captured_at'])
    return link.preview.name

Enqueue it from the view or signal that creates the link, after the transaction commits:

transaction.on_commit(lambda: capture_preview.delay(link.pk))

Notes on the task:

  • The task takes an ID, not a model. Celery serialises arguments as JSON. The task loads a fresh row and returns early if the row was deleted.
  • 429 waits for Retry-After. The SDK puts the header value in e.retry_after. Its e.retryable flag is False for a 429, because the API sends the code too_many_requests and the SDK checks a different list. That is why the task branches on e.http_status. The Python page has the full table.
  • Log before you re-raise. RenderScreenshotError needs constructor arguments that Celery cannot rebuild, so a failed task's result showed UnpickleableExceptionWrapper in our run. The log line keeps the status, code and request ID that you will quote to support.
  • ContentFile plus save=False. The storage backend writes the file once, and one UPDATE sets both columns. This works with FileSystemStorage and with django-storages backends for S3 or R2.
  • Concurrency. The per-minute rate limit is per account, so every worker and every key shares it. Run the screenshots queue on its own worker with a small --concurrency, so that captures stay under your plan's limit from the rate limits reference. For idempotency keys and webhooks, read screenshots in background jobs.

A management command to backfill

# links/management/commands/capture_previews.py
from django.core.management.base import BaseCommand

from links.models import Link
from links.tasks import capture_preview


class Command(BaseCommand):
    help = 'Capture preview images for links that have none.'

    def add_arguments(self, parser):
        parser.add_argument('--limit', type=int, default=100)
        parser.add_argument('--now', action='store_true',
                            help='Render in this process instead of enqueueing Celery tasks.')

    def handle(self, *args, limit, now, **options):
        pending = Link.objects.filter(preview='').order_by('pk')[:limit]
        for link in pending:
            if now:
                result = capture_preview.apply(args=[link.pk])
                if result.failed():
                    self.stderr.write(f'{link.url}: failed, see the log')
                else:
                    self.stdout.write(f'{link.url} -> {result.result}')
            else:
                capture_preview.delay(link.pk)
                self.stdout.write(f'queued {link.url}')

python manage.py capture_previews queues the work. With --now, the command runs the same task code in the current process, which helps on a laptop or in a one-off release step. Our stub run printed this:

capture failed for link 5: 400 invalid_url (req_stub)
https://example.com/bad: failed, see the log
https://example.com/ -> previews/2026/10/link-4.webp

One thing to know about --now: apply() runs retries immediately and ignores countdown. A rate-limited row is tried again at once, not after Retry-After. Use it for small backfills only, and let the worker handle large ones.

A template tag for a signed og:image

A signed URL lets the crawler load the card from GET /v1/screenshot directly. Your server does no work per crawl, and the HTML holds no key that can call the API.

# links/templatetags/og.py
from datetime import datetime, time, timedelta, timezone

from django import template
from renderscreenshot import TakeOptions

from links.screenshots import get_client

register = template.Library()


@register.simple_tag(takes_context=True)
def og_image_url(context, page_url=None):
    page_url = page_url or context['request'].build_absolute_uri()
    today = datetime.now(timezone.utc).date()
    monday = today - timedelta(days=today.weekday())
    # Same URL all week; always 7 to 14 days of validity (API maximum: 30).
    expires = datetime.combine(monday + timedelta(weeks=2), time.min, tzinfo=timezone.utc)
    return get_client().generate_url(TakeOptions.url(page_url).preset('og_card'), expires)
{% load og %}
<meta property="og:image" content="{% og_image_url %}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">

A tag is better than a view helper here, because every template that extends your base layout gets the card with no change to the view. build_absolute_uri() reads the Host header. Django checks that header against ALLOWED_HOSTS first, so a forged host raises DisallowedHost and never reaches the signature. Our test hit that error before we added the host to its settings. Signed URLs carry only GET parameters. generate_url() leaves out POST-only options such as wait_for_selector() without an error; the GET endpoint reference lists what a URL can hold.

A PDF from a Django template

# links/views.py
from django.http import HttpResponse
from django.template.loader import render_to_string
from renderscreenshot import RenderScreenshotError, TakeOptions

from .models import Link
from .screenshots import get_client


def report_pdf(request):
    html = render_to_string('links/report_pdf.html', {'links': Link.objects.order_by('pk')})
    options = TakeOptions.html(html).format('pdf').pdf_paper_size('a4').pdf_print_background()
    try:
        pdf = get_client().take(options)
    except RenderScreenshotError as e:
        return HttpResponse(f'PDF render failed ({e.code})', status=503)
    response = HttpResponse(pdf, content_type='application/pdf')
    response['Content-Disposition'] = 'inline; filename="links-report.pdf"'
    return response

Keep the CSS in a <style> block in the PDF template. {% static %} paths are relative to your site, and the renderer receives only a string. Django autoescapes the template variables as usual, which matters when rows contain user text. This view blocks a web worker for the length of the render. For documents longer than a few pages, run it in a Celery task and save to a FileField. Margins, headers and page ranges are in the PDF parameters.

Tests

Patch Client.take on the class, so the cached client picks up the mock too. Call .run() to execute a task body directly, and patch retry to see the countdown:

# links/tests.py (excerpt)
from unittest.mock import patch

from celery.exceptions import Retry
from django.test import TestCase, override_settings
from renderscreenshot import Client, RenderScreenshotError

from links.models import Link
from links.screenshots import get_client
from links.tasks import capture_preview

KEYS = {'API_KEY': 'rs_live_test', 'SECRET_KEY': 'rs_secret_test', 'PUBLIC_KEY_ID': 'rs_pub_test'}


@override_settings(RENDERSCREENSHOT=KEYS, MEDIA_ROOT='/tmp/rs-django-test-media')
class CapturePreviewTests(TestCase):
    def setUp(self):
        get_client.cache_clear()
        self.link = Link.objects.create(url='https://example.com/')

    def test_429_retries_after_retry_after_seconds(self):
        limited = RenderScreenshotError(429, 'too_many_requests', 'slow down', 7)
        with patch.object(Client, 'take', side_effect=limited), \
             patch.object(capture_preview, 'retry', side_effect=Retry()) as retry:
            with self.assertRaises(Retry):
                capture_preview.run(self.link.pk)
        retry.assert_called_once_with(exc=limited, countdown=7)

    def test_400_is_not_retried(self):
        bad = RenderScreenshotError(400, 'invalid_url', 'bad url')
        with patch.object(Client, 'take', side_effect=bad), \
             patch.object(capture_preview, 'retry') as retry:
            with self.assertRaises(RenderScreenshotError):
                capture_preview.run(self.link.pk)
        retry.assert_not_called()

The full suite also saves a PNG through the task, checks the PDF view's request body, and renders the template tag on a Tuesday and a Friday of the same week to prove that the URL does not change. get_client.cache_clear() in setUp matters: without it, a client built with another test's settings leaks into the next test. The SDK testing guide covers contract tests against a fake server.

Get a free API key, export it as RS_API_KEY and run python manage.py capture_previews --now --limit 5. The free plan includes 150 credits every month.

Frequently asked questions

How do I take a screenshot of a website in Django?
Install the renderscreenshot SDK, call client.take(TakeOptions.url(url).preset('og_card')) in a Celery task, and save the returned bytes to an ImageField with ContentFile. The browser runs on the API side, so your Django server needs no Chrome.
How do I generate a PDF from HTML in Django?
Render the template with render_to_string, send the string with TakeOptions.html(html).format('pdf'), and return the bytes in an HttpResponse with content_type application/pdf. Put the CSS inline, because the renderer gets a string, not your static files.
Why does Celery show UnpickleableExceptionWrapper for a failed screenshot?
RenderScreenshotError takes several required constructor arguments, so Celery cannot pickle it for the result backend and wraps it. Log e.http_status, e.code and e.request_id inside the task before you re-raise, so the details reach your logs.
Can I put a screenshot URL in og:image without exposing my API key?
Yes. Use a signed URL. A template tag signs it with your rs_secret_ key on the server, and the HTML holds only the rs_pub_ key ID, the expiry and an HMAC signature.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.