Quickstart
1. Get a key
Sign in at /login, create a key in the dashboard. Save it and its signing secret right away — the signing secret is shown once and is not stored anywhere you can retrieve it later.
Still on a hand-issued beta key? Email support@ogmake.com
and we will send you an og_live_... API key and a signing secret
the same way.
2. Render an image
POST /v1/images, Bearer-authenticated. Pass a template id and its
params; the response is the stored image's URL, not the image bytes.
curl https://ogmake.com/v1/images \
-H "Authorization: Bearer og_live_..." \
-H "Content-Type: application/json" \
-d '{
"template": "blog",
"params": {
"site": "ogmake.com",
"author": "Jane Doe",
"date": "2026-09-19",
"title": "Hello World"
}
}' {
"url": "https://ogmake.com/o/img/paid/k_.../<hash>.png",
"hash": "<hash>",
"cached": false,
"width": 1200,
"height": 630,
"format": "png",
"bytes": 48213,
"templateVersion": "2"
}
A repeat request with the same template + params is a cache hit ("cached": true) and does not count against your quota.
3. Or link straight to a signed image URL
GET /i/{keyId}/{sig}?...params renders on
first request and serves from cache after that — put it straight in
<meta property="og:image">, no server round trip needed.
sig is an HMAC-SHA256 over the request's own canonical query
string, using your key's signing secret.
The canonical string: drop sig and debug, sort remaining keys by codepoint, RFC3986-encode each key=value, join with &. Node example (save as sign.mjs, run with node sign.mjs) — this builds the canonical string from
params rather than hard-coding it, and this exact input reproduces the fixed test vector for
signing. Python, Ruby, PHP and Go versions of the same script are on the
signing page.
The core of it — building the canonical string, then the HMAC over it. The whole runnable file, imports and test vector included, is the next line down.
// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = new Set(["sig", "debug"]);
// Drops `sig`/`debug`, sorts params by key codepoint, RFC3986-encodes each
// "key=value", joins with "&".
function canonicalQuery(params) {
return Object.keys(params)
.filter((key) => !CANONICAL_EXCLUDED_KEYS.has(key))
.sort()
.map((key) => `${rfc3986Encode(key)}=${rfc3986Encode(params[key])}`)
.join("&");
}
async function signQuery(secretBase64, canonical) {
const secret = Buffer.from(secretBase64, "base64");
const key = await subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
const mac = await subtle.sign("HMAC", key, new TextEncoder().encode(canonical));
return base64UrlEncode(new Uint8Array(mac));
} Whole file — sign.mjs
// docs/snippets/sign.mjs — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Builds the canonical query string for a signed `GET /i/{keyId}/{sig}` URL
// and signs it (HMAC-SHA256, base64url). Prints the canonical string, then
// the signature, one per line.
//
// This is the fixed test vector shared with packages/core/src/signing.test.ts
// and tooling/src/snippets.test.ts — running this script must print exactly:
// format=png&template=basic&title=Hello%20World
// W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
import { webcrypto } from "node:crypto";
const { subtle } = webcrypto;
// Your key's signing secret, base64 (shown once when the key is issued).
const SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
const PARAMS = { template: "basic", title: "Hello World", format: "png" };
function base64UrlEncode(bytes) {
return Buffer.from(bytes)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// RFC3986 encoding: encodeURIComponent leaves !'()* unescaped; escape those too.
function rfc3986Encode(value) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = new Set(["sig", "debug"]);
// Drops `sig`/`debug`, sorts params by key codepoint, RFC3986-encodes each
// "key=value", joins with "&".
function canonicalQuery(params) {
return Object.keys(params)
.filter((key) => !CANONICAL_EXCLUDED_KEYS.has(key))
.sort()
.map((key) => `${rfc3986Encode(key)}=${rfc3986Encode(params[key])}`)
.join("&");
}
async function signQuery(secretBase64, canonical) {
const secret = Buffer.from(secretBase64, "base64");
const key = await subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
const mac = await subtle.sign("HMAC", key, new TextEncoder().encode(canonical));
return base64UrlEncode(new Uint8Array(mac));
}
const canonical = canonicalQuery(PARAMS);
console.log(canonical);
console.log(await signQuery(SIGNING_SECRET_BASE64, canonical));
The resulting URL:
https://ogmake.com/i/k_.../W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8?format=png&template=basic&title=Hello%20World
?debug=1 appended to a signed URL still verifies (it is excluded
from the canonical string) and returns JSON instead of image bytes, for troubleshooting.
Rate limits and quota errors
| Response | Meaning |
|---|---|
| 401 { "error": "unauthorized" } | Missing or unknown key. |
| 401 { "error": "key_revoked" } | Key exists but has been revoked. |
| 429 { "error": "rate_limited" } | Per-key burst limit. Retry-After: 10 (seconds). |
| 429 { "error": "quota_exceeded", cap, credits, resetsAt } | Past your monthly cap and its grace. resetsAt is the next
billing month's start, unix seconds. |
Signed GET /i never returns an error status for a valid
signature — an over-cap request instead gets a watermarked card (within a limited grace
budget), then falls back to whatever's already cached for those exact params, clean or
watermarked; a genuinely new param combination past that budget gets a generic fallback
image instead. An og:image URL never breaks. See the pricing page
for cap sizes and the grace window.