post_save signal, transaction.on_commit, URL written back with update().
Django sites usually end up drawing OG cards by hand with Pillow, pixel offset by pixel offset. With a template in HTML and CSS you change the design in a stylesheet, and the Python side shrinks to one request after the post is saved.
blog/signals.py
import requests
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post
API_URL = "https://imageapi.dev/v1/images?wait=true"
def generate_og_image(post_id: int) -> None:
post = Post.objects.select_related("author").get(pk=post_id)
response = requests.post(
API_URL,
headers={"Authorization": f"Bearer {settings.IMAGE_API_KEY}"},
json={
"template": "blog-og",
"modifications": {
"title": post.title,
"author": post.author.get_full_name(),
"date": post.published_at.date().isoformat(),
"site": "yourblog.com",
},
},
timeout=20,
)
response.raise_for_status()
# .update() instead of .save(): it does not fire post_save again.
Post.objects.filter(pk=post_id).update(og_image_url=response.json()["url"])
@receiver(post_save, sender=Post)
def on_post_saved(sender, instance, update_fields=None, **kwargs):
if not instance.is_published:
return
# Run after the transaction commits; with Celery, call .delay() here instead.
transaction.on_commit(lambda: generate_og_image(instance.pk))
Write the URL back with `QuerySet.update()`, not `post.save()`. A `save()` inside a `post_save` handler fires the signal again — and you get a loop that renders the same image until something times out.
With ?wait=true the finished image arrives on the same connection — usually in about a second.
{
"id": "img_01m08s3ez1zcv28h335g9923bw",
"status": "completed",
"url": "https://cdn.imageapi.dev/renders/img_01m08s3ez1zcv28h335g9923bw.png",
"duration_ms": 912
}
A hundred images a month free, then $9 for two thousand. The same post rendered twice returns the existing URL and uses no quota — so rebuilding your site costs nothing.