ogmake

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.