Docs
One endpoint. HTML and CSS in, an image out.
Quickstart
Get a key from the dashboard (email, no card, for the Free plan), then:
curl -X POST https://ogrender.dev/v1/render \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"html": "<div style=\"display:grid;place-items:center;height:100vh;font:700 64px Inter\">Hello</div>"
}' \
-o card.png
Defaults produce a 1200×630 PNG. No width/height/format required for the common case.
Authentication
Every request needs an Authorization header:
Authorization: Bearer sk_live_<32 random bytes, base64url>
Only the key's SHA-256 hash is stored server-side. There is no way to recover a lost key. Revoke it and create a new one from the dashboard.
POST /v1/render
Body is JSON, limited to 512 KB total.
| Field | Type | Default | Notes |
|---|---|---|---|
html | string | required | ≤ 200 KB. A fragment or a full document. |
css | string | optional | ≤ 100 KB. Injected as a <style> in <head>. |
width | int | 1200 | 100-2000 |
height | int | 630 | 100-2000 |
scale | 1 or 2 | 1 | Device scale factor (2 for retina-density PNGs) |
format | "png" or "jpeg" | png | |
quality | 1-100 | 90 | jpeg only |
Response
200: raw image bytes, Content-Type: image/png or image/jpeg, plus:
X-Quota-Used,X-Quota-Limit: this billing period, before this request.X-Render-Ms: server-side render time.
Errors
All errors are JSON: {"error":{"code":"...","message":"..."}}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Body failed schema validation. |
| 400 | invalid_html | Sanitizer rejected the HTML or CSS. See below. |
| 401 | invalid_key | Missing, malformed, or revoked key. |
| 413 | payload_too_large | Body over 512 KB, or html/css over their limits. |
| 429 | rate_limited | Over your plan's per-minute rate limit. See Retry-After. |
| 429 | quota_exceeded | Monthly render quota used up. Resets on the 1st, UTC. |
| 503 | busy | Render pool is full. Retry-After: 2. Rare, retry once. |
| 504 | render_timeout | Render took over 10s, usually a font or asset that never loads. |
Quota is deducted before rendering; a 5xx response refunds it automatically.
Fonts, images, and what's blocked
- Fonts: Google Fonts, via
@import url(https://fonts.googleapis.com/css2?...)in yourcss, or a<link>to the same host. Emoji and Noto fonts are installed on the box, so emoji render without any extra setup. - Images: must be
data:URIs. There is no remote image fetching. Inline your logo or photo as base64 before sending it. - Everything else network-facing is blocked: no other
<script>, no other remote stylesheet, no analytics pixel, no web font from any host but the two above. This is deliberate. See why on the homepage.
Build-time examples
Generate images once, at build time, and serve them as static files. That is the common case for blogs and marketing sites.
Astro
// scripts/generate-og.mjs: run from an npm "prebuild" script
import { writeFile } from "node:fs/promises";
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
for (const post of posts) {
const res = await fetch("https://ogrender.dev/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OGRENDER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: `<div style="display:flex;align-items:center;height:100vh;
padding:80px;font:700 56px Inter;background:#111;color:#fff">
${post.data.title}</div>`,
}),
});
await writeFile(`public/og/${post.slug}.png`, Buffer.from(await res.arrayBuffer()));
}
Next.js
// scripts/generate-og.mjs: run before "next build"
import { writeFile, mkdir } from "node:fs/promises";
import { posts } from "../lib/posts.mjs";
await mkdir("public/og", { recursive: true });
for (const post of posts) {
const res = await fetch("https://ogrender.dev/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OGRENDER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html: renderCardHtml(post) }), // your own template fn
});
await writeFile(`public/og/${post.slug}.png`, Buffer.from(await res.arrayBuffer()));
}
Hugo
#!/usr/bin/env node
// scripts/generate-og.mjs: run before "hugo", reads front matter directly
import { readFile, writeFile, readdir } from "node:fs/promises";
import matter from "gray-matter";
for (const file of await readdir("content/posts")) {
const { data, content } = matter(await readFile(`content/posts/${file}`, "utf8"));
const res = await fetch("https://ogrender.dev/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OGRENDER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: `<div style="display:flex;align-items:center;height:100vh;
padding:80px;font:700 56px Inter;background:#111;color:#fff">
${data.title}</div>`,
}),
});
await writeFile(`static/og/${data.slug}.png`, Buffer.from(await res.arrayBuffer()));
}
Eleventy
// .eleventy.js: an async shortcode, cached per build by Eleventy itself
const fs = require("node:fs/promises");
module.exports = function (eleventyConfig) {
eleventyConfig.addAsyncShortcode("ogImage", async (slug, title) => {
const out = `og/${slug}.png`;
const res = await fetch("https://ogrender.dev/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OGRENDER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: `<div style="display:flex;align-items:center;height:100vh;
padding:80px;font:700 56px Inter;background:#111;color:#fff">
${title}</div>`,
}),
});
await fs.writeFile(`_site/${out}`, Buffer.from(await res.arrayBuffer()));
return `/${out}`;
});
};
Support
Email support@ogrender.dev. Include the error code from the response and roughly when the request was made.