Starhold Render

An API that makes Open Graph images

Send a template and a few words, get a 1200 by 630 PNG back. Or send your own HTML and CSS. Signed URLs let you put the image straight into your og:image tag.

Get a free key Full reference

Request and response

Send JSON with your key in the Authorization header. A good request returns the file itself with a Content-Type, an X-Render-Usage header such as 12/1000, and X-Render-Cache: hit or miss. A bad one returns JSON with a code, a plain sentence and a link to the docs.

curl
curl -X POST https://render.starhold.app/v1/og \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"template":"basic","params":{"title":"Hello from Starhold","subtitle":"Made with one API call"}}' \
  -o og.png
JavaScript (fetch)
import { writeFile } from "node:fs/promises";

const res = await fetch("https://render.starhold.app/v1/og", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
  body: '{"template":"basic","params":{"title":"Hello from Starhold","subtitle":"Made with one API call"}}',
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("og.png", Buffer.from(await res.arrayBuffer()));
Python (requests)
import requests

body = '{"template":"basic","params":{"title":"Hello from Starhold","subtitle":"Made with one API call"}}'
res = requests.post("https://render.starhold.app/v1/og",
    headers={"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"},
    data=body.encode(), timeout=35)
res.raise_for_status()
open("og.png", "wb").write(res.content)
PHP (curl ext)
<?php
$body = '{"template":"basic","params":{"title":"Hello from Starhold","subtitle":"Made with one API call"}}';
$ch = curl_init("https://render.starhold.app/v1/og");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_API_KEY", "Content-Type: application/json"],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 35,
]);
$out = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) { die($out); }
file_put_contents("og.png", $out);
Go (net/http)
package main

import (
	"io"
	"net/http"
	"os"
	"strings"
	"time"
)

func main() {
	body := `{"template":"basic","params":{"title":"Hello from Starhold","subtitle":"Made with one API call"}}`
	req, _ := http.NewRequest("POST", "https://render.starhold.app/v1/og", strings.NewReader(body))
	req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
	req.Header.Set("Content-Type", "application/json")
	res, err := (&http.Client{Timeout: 35 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		io.Copy(os.Stderr, res.Body)
		os.Exit(1)
	}
	out, _ := os.Create("og.png")
	io.Copy(out, res.Body)
}

Signed URL for a meta tag

curl
# Keys sorted, values percent-encoded. Run this on your server: the secret never goes to a browser.
SECRET=YOUR_SIGNING_SECRET
QUERY="k=YOUR_KEY_ID&subtitle=Signed%2C%20so%20it%20is%20safe%20to%20publish&title=Hello%20from%20Starhold"
SIG=$(printf 'GET\n/v1/og/basic.png\n%s' "$QUERY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -o og.png "https://render.starhold.app/v1/og/basic.png?$QUERY&s=$SIG"
JavaScript (fetch)
import { createHmac } from "node:crypto";

// RFC 3986 percent-encoding: only A-Z a-z 0-9 - . _ ~ stay literal.
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
const params = {"title": "Hello from Starhold", "subtitle": "Signed, so it is safe to publish"};
const pairs = Object.entries({ ...params, k: "YOUR_KEY_ID" }).sort(([a], [b]) => (a < b ? -1 : 1));
const query = pairs.map(([k, v]) => `${enc(k)}=${enc(v)}`).join("&");
const path = "/v1/og/basic.png";
const sig = createHmac("sha256", "YOUR_SIGNING_SECRET").update(`GET\n${path}\n${query}`).digest("hex");
const url = `https://render.starhold.app${path}?${query}&s=${sig}`; // put this in <meta property="og:image">
Python (requests)
import hashlib, hmac
from urllib.parse import quote

params = {"title": "Hello from Starhold", "subtitle": "Signed, so it is safe to publish"}
pairs = sorted({**params, "k": "YOUR_KEY_ID"}.items())
query = "&".join(f"{quote(k, safe='-._~')}={quote(v, safe='-._~')}" for k, v in pairs)
path = "/v1/og/basic.png"
sig = hmac.new(b"YOUR_SIGNING_SECRET", f"GET\n{path}\n{query}".encode(), hashlib.sha256).hexdigest()
url = f"https://render.starhold.app{path}?{query}&s={sig}"  # put this in <meta property="og:image">
PHP (curl ext)
<?php
$params = ["title" => "Hello from Starhold", "subtitle" => "Signed, so it is safe to publish", "k" => "YOUR_KEY_ID"];
ksort($params);  // keys sorted; rawurlencode is RFC 3986
$query = implode("&", array_map(fn($k, $v) => rawurlencode($k) . "=" . rawurlencode($v), array_keys($params), $params));
$path = "/v1/og/basic.png";
$sig = hash_hmac("sha256", "GET\n$path\n$query", "YOUR_SIGNING_SECRET");
$url = "https://render.starhold.app$path?$query&s=$sig";  // put this in <meta property="og:image">
Go (net/http)
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"sort"
	"strings"
)

// RFC 3986: only A-Z a-z 0-9 - . _ ~ stay literal.
func enc(s string) string {
	var b strings.Builder
	for _, c := range []byte(s) {
		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("-._~", c) >= 0 {
			b.WriteByte(c)
		} else {
			fmt.Fprintf(&b, "%%%02X", c)
		}
	}
	return b.String()
}

func main() {
	params := map[string]string{"title": "Hello from Starhold", "subtitle": "Signed, so it is safe to publish", "k": "YOUR_KEY_ID"}
	keys := make([]string, 0, len(params))
	for k := range params {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, enc(k)+"="+enc(params[k]))
	}
	query := strings.Join(parts, "&")
	path := "/v1/og/basic.png"
	mac := hmac.New(sha256.New, []byte("YOUR_SIGNING_SECRET"))
	mac.Write([]byte("GET\n" + path + "\n" + query))
	fmt.Println("https://render.starhold.app" + path + "?" + query + "&s=" + hex.EncodeToString(mac.Sum(nil))) // for <meta property="og:image">
}

Main options

FieldWhat it does
templatebasic, article, product or docs, with params checked per template
html, cssyour own markup instead of a template (html up to 256 KB, css up to 64 KB)
width, height200 to 2400 pixels, default 1200 by 630
format, qualitypng, jpeg or webp; quality 1 to 100
cache_ttl0 to 86,400 seconds, default 86,400

Pricing in short

Free is 50 renders a month. Hobby is $5 for 1,000, Pro is $19 for 10,000 and Scale is $49 for 50,000. Cache hits and failed renders do not count. Full pricing

Questions

Can I use it in a meta tag?

Yes. Sign a URL on your server with your signing secret and put it in og:image. See signed URLs in the docs. Cache hits on signed URLs cost you nothing.

Do you fetch my images?

Only https:// images you name in a template, through the same guard as every other fetch. Private addresses are refused.

What does the Free plan add?

A small watermark in the bottom-right corner. Paid plans never have one.