Templates and signed URLs

Save the HTML once. Then a plain URL, with your data baked in and a signature nobody else can forge, is the image.

Quickstart

Three calls: save a template, sign a URL for it, use that URL as og:image. The rest of this page is the detail behind each step.

1. Save a template (session cookie or Authorization: Bearer sk_live_...):

POST /v1/templates201 application/json
curl -X POST https://ogrender.dev/v1/templates \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "welcome",
    "html": "<div style=\"display:flex;align-items:center;justify-content:center;height:100%;font:700 48px Inter;background:#111;color:#fff\">Welcome, {{name}}!</div>"
  }'
response body201 application/json
{
  "id": "tpl_b93c09d5a978e467",
  "user_id": 1,
  "name": "welcome",
  "html": "<div style=...>Welcome, {{name}}!</div>",
  "css": null,
  "width": 1200, "height": 630, "format": "png", "scale": 1, "quality": 90,
  "created_at": "2026-09-19 03:19:27", "updated_at": "2026-09-19 03:19:27"
}

2. Sign a URL for it with your account's signing secret (from GET /v1/me). See signing URLs below for the exact rule and code in three languages.

3. Use it as a normal image URL, no API call from your server at all:

og:image tagno server call
<meta property="og:image" content="https://ogrender.dev/v1/img/tpl_b93c09d5a978e467.png?name=Ada%20Lovelace&sig=0546747a2f0ddf9e03242a9330f66ca1e8fa12a7cea38ed1a5f01bd783226927">

The first request to that URL renders the image and charges your account; every request after, from anyone, for 24 hours, is served from cache and costs nothing.

Templates

Session cookie or Bearer key. 100 templates per account, then 409 template_limit. Requesting or changing another account's template is 404 not_found, never 403: their template ids are not something you should learn exist.

MethodPathBodyResponse
POST/v1/templates{name, html, css?, width?, height?, format?, scale?, quality?}201, full row
GET/v1/templates200, array without html/css
GET/v1/templates/:id200, full row
PUT/v1/templates/:idsame as POST200, full row
DELETE/v1/templates/:id204
GET /v1/templates200 application/json
curl https://ogrender.dev/v1/templates -H "Authorization: Bearer sk_live_..."
response body200 application/json
[
  { "id": "tpl_b93c09d5a978e467", "name": "welcome", "width": 1200, "height": 630,
    "format": "png", "scale": 1, "quality": 90,
    "created_at": "2026-09-19 03:19:27", "updated_at": "2026-09-19 03:19:27" }
]

name is 1 to 100 characters, html up to 200 KB, css up to 100 KB, same as a raw render. Defaults: width 1200, height 630, format png, scale 1, quality 90. On save the template renders once with every variable empty through the same sanitizer as /v1/render; a template that could never render is 400 invalid_html here, at save time, instead of failing on every signed URL later.

Variables

Write {{name}} anywhere in html or css (surrounding spaces are fine: {{ name }} also works). Names match ^[A-Za-z_][A-Za-z0-9_]{0,63}$; a key that does not match is 400 invalid_request. Up to 50 keys and 4 KB of keys plus values combined.

Example variables body: {"name": "Ada Lovelace", "score": "42"}.

Rendering a template directly

From your own backend, skip the signed URL and call /v1/render with a template id instead of raw HTML:

POST /v1/render200 image/png
curl -X POST https://ogrender.dev/v1/render \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "template": "tpl_b93c09d5a978e467",
    "variables": { "name": "Grace" }
  }' -o welcome-grace.png

Any of width, height, format, scale, quality on this body override the template's stored values for that one call. An unknown template id is 404 not_found. Full field list and errors are in the main docs.

Signing image URLs

GET /v1/img/<template id>.<png|jpeg>?<variables>&sig=<hex>, no API key: the signature is what authenticates the request. The signed string is

signed stringhmac-sha256 input
<template id> \n <ext> \n <query>

where <query> is every query pair except sig, sorted by key, written as enc(key)=enc(value) and joined with & (empty string if there are no variables). enc is strict RFC 3986 percent-encoding: exactly what Python's quote(value, safe='') and PHP's rawurlencode produce, and what JavaScript's encodeURIComponent produces once ! ' ( ) * are encoded too, since it is the one encoder that leaves those five bare. sig is the hex HMAC-SHA256 of that string under your account's signing secret (from GET /v1/me, rotated with POST /v1/signing-secret/rotate).

The query in the URL you publish must already be in that exact canonical form. ?name=%41 and ?name=A decode to the same variable but are two different strings to sign, so only one of them can match a given signature; a bare key with no = and a + used for a space are refused the same way. Build the URL from the same encoding step you sign, never by hand-editing it afterward. Any mismatch, any extra or missing key, any unknown template id, or any unsupported extension is 403 invalid_signature with Cache-Control: no-store, so nobody can append a parameter and mint a different image on your account.

Node

nodesigns GET /v1/img/:id.:ext
import { createHmac } from "node:crypto";

function signImageUrl(secret, id, ext, variables) {
  const enc = (s) => encodeURIComponent(s)
    .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
  const query = Object.keys(variables).sort()
    .map((k) => `${enc(k)}=${enc(variables[k])}`)
    .join("&");
  const sig = createHmac("sha256", secret)
    .update(`${id}\n${ext}\n${query}`)
    .digest("hex");
  return `https://ogrender.dev/v1/img/${id}.${ext}?${query}&sig=${sig}`;
}

Python

pythonsigns GET /v1/img/:id.:ext
import hmac, hashlib
from urllib.parse import quote

def sign_image_url(secret: bytes, id: str, ext: str, variables: dict) -> str:
    query = "&".join(
        f"{quote(k, safe='')}={quote(str(v), safe='')}"
        for k, v in sorted(variables.items())
    )
    sig = hmac.new(secret, f"{id}\n{ext}\n{query}".encode(), hashlib.sha256).hexdigest()
    return f"https://ogrender.dev/v1/img/{id}.{ext}?{query}&sig={sig}"

PHP

phpsigns GET /v1/img/:id.:ext
function sign_image_url(string $secret, string $id, string $ext, array $variables): string {
    ksort($variables);
    $pairs = [];
    foreach ($variables as $k => $v) {
        $pairs[] = rawurlencode($k) . "=" . rawurlencode((string) $v);
    }
    $query = implode("&", $pairs);
    $sig = hash_hmac("sha256", "$id\n$ext\n$query", $secret);
    return "https://ogrender.dev/v1/img/$id.$ext?$query&sig=$sig";
}

Two fixed test vectors, the same ones tests/img.test.js asserts server-side. Every one of the three snippets above reproduces both signatures exactly. Both use the secret 0000000000000000000000000000000000000000000000000000000000000000 (64 zeros), the id tpl_0123456789abcdef and the ext png, so the signed string is tpl_0123456789abcdef\npng\n followed by the query.

variablesquerysig
{"name": "Ada Lovelace", "score": "42"}name=Ada%20Lovelace&score=423b526d8e091a9e1d880c1b53128114d7fba5acf487d864d6e733499395964233
{"name": "O'Brien (1)!*", "score": "42"}name=O%27Brien%20%281%29%21%2A&score=42691eb6cdfee56a172a6a723e4e9596348a8bd89cf8d317317fd388fa5ae9f89e

Reproduce both vectors before shipping: a mismatch means every signed URL you generate is a silent 403, and the second one is the one that catches the common bug. The three snippets above agree because enc is RFC 3986 in all of them: Python's quote(safe='') and PHP's rawurlencode are already strict, while encodeURIComponent alone leaves ! ' ( ) * bare, which is why the Node snippet re-encodes those five. An encoder that emits + for a space, or that leaves an apostrophe or a parenthesis unescaped, will pass the first vector and fail on the first customer named O'Brien.

Response

200: the image, Content-Type: image/png or image/jpeg, Cache-Control: public, max-age=86400, ETag, X-Cache: HIT|MISS. Send If-None-Match and get 304 with no body. A cache hit or a 304 does not touch your quota or credits and skips the rate limiter entirely, since it never reaches the renderer. On a miss, the normal rate limit and consumeRender apply before rendering, so an exhausted account gets 429 here too, with no-store so a crawler retries later instead of caching the error.

Caching and charging

A render is one origin Chromium run that returned 2xx. Not charged: a content-cache hit (same account, identical document and options, within 24 hours), a signed-URL cache hit or 304, playground output, any 4xx, and a 5xx other than 504. Plan quota is spent first; once it is used up for the month, the account spends a credit instead of getting a hard 429, because a 429 on a live og:image tag is a broken page. Quota resets on the 1st of the month, UTC; credits never expire and are not tied to a billing period.

The content cache is keyed by a hash of your account id, the rendered document and the render options, so one account can never read or collide with another's cached entry. Nothing about the cache changes what you see: identical input still returns byte-identical output, just faster and free on a repeat.

Credits

A $12 pack grants 1,000 credits, spent only after your plan's monthly quota (if any) runs out, and never expiring. GET /v1/me and GET /billing/config both report your current balance. Credits are personal to your account: see terms for the non-transferable rule and refunds for what a refunded pack does to your balance.