OG images in Django
The fastest path for a Django app is the signed GET /i/{keyId}/{sig}?...params URL: it renders on first request and serves from cache after that, so it can go straight
into a template's <meta property="og:image"> with no call
to the ogmake API from your view at request time.
1. Get a key
Sign in at /login and create a key in the dashboard. The signing secret
is shown once — save it in your environment as, for example, OGMAKE_SIGNING_SECRET and read it with os.environ or your settings module, not
hard-coded.
Still on a hand-issued beta key? Email support@ogmake.com.
2. Sign the URL in Python
The canonical string: drop sig and debug, sort remaining keys by Unicode code point (Python's default string ordering),
RFC3986-encode each key=value, join with &, then HMAC-SHA256 it with your key's signing
secret. The file below is the real, interpreter-run script (docs/snippets/sign.py, run with python3 sign.py) also used on the
signing page — it reproduces the same fixed test vector, so it
is provably correct rather than just plausible. A Django view or template tag does the same
two calls (canonical_query, sign_query) against your own params.
#!/usr/bin/env python3
"""docs/snippets/sign.py — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
Same fixed test vector as sign.mjs; run with `python3 sign.py`, prints:
format=png&template=basic&title=Hello%20World
W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
"""
import base64
import hashlib
import hmac
import urllib.parse
# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = {"template": "basic", "title": "Hello World", "format": "png"}
def rfc3986_encode(value: str) -> str:
# safe="" — Python's `quote` always leaves unreserved chars (letters,
# digits, "_.-~") alone; nothing else should be left unescaped.
return urllib.parse.quote(value, safe="")
# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = {"sig", "debug"}
def _utf16_key(value: str):
# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Python's `sorted()`, which compares strings by
# Unicode code point. The two orders agree for BMP-only keys but diverge
# for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# code point order puts it last. Using plain `sorted()` here would build
# a different canonical string than the server and produce a bad
# signature for such keys.
units = []
for ch in value:
cp = ord(ch)
if cp > 0xFFFF:
cp -= 0x10000
units.append(0xD800 + (cp >> 10))
units.append(0xDC00 + (cp & 0x3FF))
else:
units.append(cp)
return units
def canonical_query(params: dict) -> str:
filtered = {k: v for k, v in params.items() if k not in CANONICAL_EXCLUDED_KEYS}
return "&".join(
f"{rfc3986_encode(k)}={rfc3986_encode(v)}"
for k, v in sorted(filtered.items(), key=lambda kv: _utf16_key(kv[0]))
)
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def sign_query(secret_base64: str, canonical: str) -> str:
secret = base64.b64decode(secret_base64)
mac = hmac.new(secret, canonical.encode(), hashlib.sha256).digest()
return base64url_encode(mac)
if __name__ == "__main__":
canonical = canonical_query(PARAMS)
print(canonical)
print(sign_query(SIGNING_SECRET_BASE64, canonical))
3. Put it in the template
Compute the signed URL in the view and pass it into the template context like any other variable:
# blog/views.py
def post_detail(request, slug):
post = get_object_or_404(Post, slug=slug)
canonical = canonical_query({
"template": "blog",
"site": "example.com/blog",
"author": post.author.name,
"date": post.published_at.strftime("%b %-d, %Y"),
"title": post.title,
})
sig = sign_query(settings.OGMAKE_SIGNING_SECRET, canonical)
# OGMAKE_BASE_URL = "https://ogmake.com" (settings.py)
og_image_url = f"{settings.OGMAKE_BASE_URL}/i/{settings.OGMAKE_KEY_ID}/{sig}?{canonical}"
return render(request, "blog/post_detail.html", {"post": post, "og_image_url": og_image_url}) {# blog/templates/blog/post_detail.html #}
<meta property="og:image" content="{{ og_image_url }}">
<meta name="twitter:card" content="summary_large_image"> Or pre-render with the JSON API
If you'd rather store a URL once (e.g. in a post-save signal, cached on the model) than sign
one per request, POST /v1/images with requests:
import requests
resp = requests.post(
"https://ogmake.com/v1/images",
headers={"Authorization": f"Bearer {settings.OGMAKE_API_KEY}"},
json={
"template": "blog",
"params": {
"site": "example.com/blog",
"author": post.author.name,
"date": post.published_at.strftime("%b %-d, %Y"),
"title": post.title,
},
},
)
og_image_url = resp.json()["url"] curl https://ogmake.com/v1/images \
-H "Authorization: Bearer og_live_..." \
-H "Content-Type: application/json" \
-d '{
"template": "blog",
"params": {
"site": "example.com/blog",
"author": "Jane Doe",
"date": "Sep 20, 2026",
"title": "Hello World"
}
}' Full field list per template on the reference page.