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
renderscreenshot1.0.2.manage.py testpassed 5 tests.capture_previews --nowran against a local stub that returns the documented image and error responses, including a 429 withRetry-After. We did not call the live API or run a Celery worker against Redis. The tasks ran with Celery'sapply().
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 ine.retry_after. Itse.retryableflag isFalsefor a 429, because the API sends the codetoo_many_requestsand the SDK checks a different list. That is why the task branches one.http_status. The Python page has the full table. - Log before you re-raise.
RenderScreenshotErrorneeds constructor arguments that Celery cannot rebuild, so a failed task's result showedUnpickleableExceptionWrapperin our run. The log line keeps the status, code and request ID that you will quote to support. ContentFileplussave=False. The storage backend writes the file once, and oneUPDATEsets both columns. This works withFileSystemStorageand 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
screenshotsqueue 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.
Related
- HTML to image in Python compares running Playwright yourself with this API.
- SDK source and changelog: github.com/Render-Screenshot/rs-python.
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.