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.

FieldTypeDefaultNotes
htmlstringrequired≤ 200 KB. A fragment or a full document.
cssstringoptional≤ 100 KB. Injected as a <style> in <head>.
widthint1200100-2000
heightint630100-2000
scale1 or 21Device scale factor (2 for retina-density PNGs)
format"png" or "jpeg"png
quality1-10090jpeg 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":"..."}}

StatusCodeMeaning
400invalid_requestBody failed schema validation.
400invalid_htmlSanitizer rejected the HTML or CSS. See below.
401invalid_keyMissing, malformed, or revoked key.
413payload_too_largeBody over 512 KB, or html/css over their limits.
429rate_limitedOver your plan's per-minute rate limit. See Retry-After.
429quota_exceededMonthly render quota used up. Resets on the 1st, UTC.
503busyRender pool is full. Retry-After: 2. Rare, retry once.
504render_timeoutRender 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 your css, 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.