Signed URLs
Docs · OG · PDF · Screenshot · Signed URLs · Errors · Limits
What they are for
A signed URL returns a built-in-template OG image with no API key, so you can put it in <meta property="og:image">. Social crawlers fetch it; cache hits are free and have their own generous limit. The first render of a new URL counts against your allowance.
GET /v1/og/{template}.png?...params&k=KEY_ID&exp=UNIX_TIME&s=SIGNATURE
How to sign
- Take your params, add
k(your key id,shk_...) and, if you want an expiry,exp(a unix time). Do not includes. - Sort the pairs by key. Each key may appear once. Percent-encode keys and values per RFC 3986: only
A-Z a-z 0-9 - . _ ~stay as they are. - Join as
key=valuewith&to get the query. - The signature is the hex HMAC-SHA256, with your signing secret, of
GET, a newline, the path (for example/v1/og/basic.png), a newline, and the query. - Append
&s=and the signature.
A wrong or missing signature, or a past exp, returns bad_signature. A repeated key returns invalid_request. Rotating your signing secret on the dashboard stops every old signed URL from working. The dashboard can also build a signed URL for you.
Keep the signing secret on your server. Anyone who has it can make images on your allowance.
Examples
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">
}