Login Sign up

Documentation#

Everything you need to use media0 (https://media0.eu): account access, the template authoring guide, and the full API reference. Legal, privacy and compliance live in https://media0.eu/legal.txt.

Overview#

media0 offers programmatic images. Upload an HTML template as JSON once; every combination of parameters becomes an immutable, edge-cacheable webp — social cards, changelog banners, course certificates — no image pipeline to run.

GET https://<tenant>.media0.eu/image/launch?title=Programmatic+images+for+every+asset&badge=media0
200 image/webp · Cache-Control: public, max-age=86400, immutable

How it works:

  1. POST a template — one JSON spec: HTML, width/height, and typed params (string, number, image_url, data_url). Validate it for free first, preview drafts before publishing.
  2. Versions are immutable — every publish mints an ascending version (000001, 000002, …). Old URLs keep rendering exactly what they always did.
  3. Aliases 302 to latest — point /image/certificate at a template; consumers hit the alias and are redirected to the fully-qualified, immutable render URL.
  4. Rendered once, cached hard — headless Chromium renders on the first request; the webp is cached with a 24-hour sliding TTL, and immutable URLs mean any CDN in front caches them as an ordinary proxy.

Built agent-first: the whole lifecycle is three HTTP calls with JSON in and JSON out, validation is a free dry-run, and even signup works without a human (see Signup as AI below).

Limits (every tenant, same defaults): 50 templates per tenant with unlimited immutable versions; 10,000 renders/month counted on cache-miss render attempts only (successful or failed — a rate limit, not billing; cached traffic is free and unmetered); 3 s render budget; 24 h sliding image cache; webp output, default 1200×630 @ 2× scale, up to 4096 px; template HTML up to 1 MB with up to 32 declared params.

Access#

Signup as human#

Sign up in the browser at https://media0.eu/app/#/signup. You provide one email address (required, must be unused) and register one passkey (WebAuthn) — there are no passwords. When the passkey is registered your tenant is created, you are signed in, and the console shows 10 single-use recovery codes exactly once. Save them somewhere safe: each code can sign in as your account, each works only once, and they are never displayed again (you can regenerate a fresh set from the console at any time).

Signup as AI#

Agents do not need a human to get an account. Agent signup is gated by a Hashcash-style proof of work — a few CPU-seconds instead of email verification or CAPTCHAs — and returns everything you need in one response.

1. Request a challenge (rate-limited per IP):

curl -s -X POST https://media0.eu/auth/pow/challenge
# -> 201 {"challenge": "<64 hex chars>", "bits": 22, "expires_at": "…"}

The challenge is single-use and expires after 10 minutes. It is also burned on a failed signup attempt — if your signup call fails, request a fresh challenge and re-solve.

2. Solve it: find an ASCII nonce (1–64 chars) such that sha256(challenge + ":" + nonce) has at least bits leading zero bits. A simple counter loop works — roughly 4M hashes at 22 bits, a couple of CPU-seconds.

import hashlib
import itertools

def solve_pow(challenge: str, bits: int) -> str:
    for i in itertools.count():
        nonce = str(i)
        digest = hashlib.sha256(f"{challenge}:{nonce}".encode()).digest()
        if int.from_bytes(digest, "big") >> (256 - bits) == 0:
            return nonce  # >= `bits` leading zero bits

3. Sign up (name and email optional; an already-used email is a 409):

curl -s https://media0.eu/auth/agent/signup \
  -d '{"challenge": "<challenge>", "nonce": "<nonce>", "name": "my-bot"}'
{
  "tenant_id": "6f1d2e7a-…",
  "api_key": { "id": "…", "secret": "m0_…", "scope": "write", "prefix": "m0_ab12" },
  "recovery_codes": ["xxxxx-xxxxx", "… 10 single-use codes …"],
  "claim_url": "https://media0.eu/app/#/claim?tenant=6f1d2e7a-…"
}

What to do with the response:

A failed proof of work or a stale/reused challenge is a 400 — get a fresh challenge and retry. No session cookie is set by this endpoint.

Human claim of an AI signup#

The claim_url points to a console page where a human takes ownership of the agent-created account. The human opens the URL and redeems one of the ten recovery codes (the code is never part of the URL) — this signs them in with a console session. From there they register one or more passkeys and optionally set an account email, so future sign-ins no longer depend on recovery codes.

Nothing about the account itself changes: it is the same tenant, and the agent's API key keeps working. The claim simply gives a human durable browser access — and the ability to manage passkeys, regenerate recovery codes, set the email, and manage API keys from the console.

Signin as human#

Sign in at https://media0.eu/app/. The console offers three methods:

Passkey and recovery-code sign-ins establish a console session: an HttpOnly cookie lasting 30 days (sliding — activity prolongs it). A session authenticates all /api/v1 requests with full write scope, no Authorization header needed.

How to configure custom subdomains?#

Every tenant already has https://<tenant-id>.media0.eu out of the box — nothing to configure. To serve images from your own domain instead (img.example.com):

  1. Register the domain with your tenant: in the console under Settings (https://media0.eu/app/#/settings), add it to "CNAME domains" (one per line) — or via the API:

    curl -s -X PUT https://media0.eu/api/v1/config \
      -H "Authorization: Bearer $M0_KEY" \
      -d '{"cname_domains": ["img.example.com"]}'
    

    The domain must not be under media0.eu itself.

  2. Point DNS at the platform: create a CNAME record at your DNS provider:

    img.example.com.  CNAME  media0.eu.
    
  3. TLS is automatic: the first HTTPS request to the new domain mints a certificate on demand (only domains registered in step 1 are issued certificates). That first request can take a few seconds; everything after is instant.

Your custom domain then behaves exactly like the tenant subdomain: https://img.example.com/image/<alias>?… and /api/v1 both work.

How to configure CSPs?#

If the page embedding your images sends a Content-Security-Policy header, the image origin must be allowed in img-src. Two ways, depending on how you serve images:

One origin is always enough: alias URLs 302-redirect to the immutable render URL on the same host, so no second origin ever appears. If you use the path plane (https://media0.eu/tenants/<id>/image/…) allow https://media0.eu instead.

Guide#

How to author media0 templates programmatically. Written for AI agents: every step is a plain HTTP call with JSON in and JSON out, every failure is machine-readable, and validation is free.

Need an account or an API key first? See Access — it covers agent signup (proof-of-work), API keys, and handing the account to a human.

The spec schema#

A template is one JSON document:

{
  "name": "launch-card",        // optional, informational (shown in UI)
  "width": 1200,                // optional, CSS px, default 1200, 1..4096
  "height": 630,                // optional, CSS px, default 630, 1..4096
  "scale": 2,                   // optional, deviceScaleFactor, default 2, 0.5..4
  "html": "<!doctype html>…",   // REQUIRED: a Go html/template document
  "params": {                   // optional: declared query parameters
    "title": {
      "type": "string",         // "string" (default) | "number" | "image_url" | "data_url"
      "default": "Untitled",    // used when the query param is absent
      "required": false,        // true -> render is 400 without it
      "maxLen": 120,            // value length cap, default 1000
      "description": "Main headline"  // for humans and agents
    }
  }
}

Rules:

Space-efficient list params (charts, leaderboards, …)#

Model list-like data as one compact comma-separated param, not one param per item:

GET /image/chart?values=42,58,71,96&labels=Mar,Apr,May,Jun    ✓
GET /image/chart?v1=42&v2=58&v3=71&v4=96&l1=Mar&l2=Apr&…      ✗

Repeated query keys collapse (the first value wins), specs cap out at 32 declared params, and per-item params hard-code the item count into the template. Do the splitting in the HTML instead:

{{range $i, $v := split .values ","}}
  <div class="bar" style="--v: {{$v}}; --i: {{$i}}"><span>{{$v}}</span></div>
{{end}}
{{if .labels}}{{range split .labels ","}}<span>{{.}}</span>{{end}}{{end}}

Pair parallel lists by position with the built-in index ({{index (split .labels ",") $i}}), derive per-item styling from the loop index (calc(var(--i)*14)), and keep values free of spaces after commas — they interpolate as-is. The library's Bar Chart Card is a working example.

Email charts: your application data, charted, in every email#

A core media0 use case. Email clients strip <script> and <canvas>, so you cannot ship a live chart in an email — and pre-rendering and attaching images bloats every send and goes stale the moment it leaves. The media0 pattern instead: publish a chart template once, then embed one image URL per email — the chart data rides along as query params your app fills in at send time:

<img width="600" alt="Sign-ups this week"
  src="https://{tenant}.media0.eu/image/t/{id}?title=Sign-ups&values=38,52,61,45,74,29,33&total=332">

The Email Charts library category (/library/email-charts) ships ready-made, flavored templates for exactly this: weekly digest bars, stacked revenue mix, a ranked top-10 with the long tail folded into "Everything else", a gradient growth area, a world map of your visitors, a European choropleth, a country spotlight and an orthographic globe for market launches. Fork one, keep the spec, change nothing but the params.

The public template library at /library ships proven, ready-made specs by use case (Open Graph images, certificates, badges, …). Every template is available machine-readable — no auth, no quota:

# index: every library template with its page and raw-JSON URL
curl -s $BASE/library.json
# -> {"templates": [{"id": "…", "name": "…", "category": "…",
#      "sample_params": {…}, "page": "…", "json": "…"}, …]}

# one template: append .json to its canonical detail-page URL
curl -s $BASE/library/open-graph-images/{id}/{slug}.json
# -> {"id": "…", "name": "…", "description": "…",
#     "sample_params": {"title": "…"}, "spec": {…the full spec…}}

Take the spec field as-is (or tweak it) and publish it as your own template with POST /api/v1/templates below; sample_params are known-good render params for a first preview. GET /library/t/{id}.json is a stable id-only alias that 301s to the canonical .json URL.

The loop: validate → preview → create → render#

Assume BASE (e.g. https://media0.eu), a key in M0_KEY, and the tenant id TENANT (from GET /api/v1/whoami → tenant_id).

1. Validate (free, unlimited, never renders):

curl -s $BASE/api/v1/templates/validate \
  -H "Authorization: Bearer $M0_KEY" \
  -d '{"spec": {"html": "<h1 class=\"og-title\">{{.title}}</h1>", "params": {"title": {"required": true}}}}'
# -> {"ok": true|false, "problems": ["html: does not parse …", …], "spec": {…normalized…}}

Iterate on the spec until ok is true. The returned spec shows the applied defaults (width/height/scale/param types).

2. Preview the draft (optional — see it before publishing):

curl -s $BASE/api/v1/templates/preview -H "Authorization: Bearer $M0_KEY" \
  -o draft.webp -d '{"spec": { …the validated spec… }, "params": {"title": "Hello"}}'

Returns the rendered webp without creating anything. A changed spec+params combination is a real render (counts against the monthly quota); unchanged repeats are free cache hits. Iterate here until the image looks right, then publish.

3. Create (write key; costs one of the 50 template slots):

curl -s $BASE/api/v1/templates \
  -H "Authorization: Bearer $M0_KEY" \
  -d '{"name": "launch-card", "spec": { …the validated spec… }}'
# -> 201 {"id": "TID", "latest_version": "000001", …}

To iterate on an existing template instead, publish a new version: PUT /api/v1/templates/{TID} with {"spec": {…}} — old versions and their URLs are immutable and keep working.

4. Verify the published version (public image plane, no auth):

curl -sL -o preview.webp \
  "$BASE/tenants/$TENANT/image/t/$TID/000001/webp?title=Hello+world"

Inspect the webp; if it is wrong, go back to step 1 and publish a new version. Each distinct param combination that misses the cache costs one render from the 10,000/month quota — reuse identical preview params while iterating so repeats are free cache hits.

5. Optionally alias it for consumers:

curl -s -X PUT $BASE/api/v1/aliases/launch \
  -H "Authorization: Bearer $M0_KEY" -d "{\"template_id\": \"$TID\"}"
# consumers use: $BASE/tenants/$TENANT/image/launch?title=…

Sandbox rules (what your HTML may do)#

The renderer is headless Chromium behind a request interceptor. Hard rules — violations don't error, the resource simply fails to load:

Tips for deterministic 1200×630 layouts#

System-prompt snippet for a template-designing agent#

Copy-paste, fill in the three variables:

You design image templates for media0, an HTML-to-webp rendering service.
API base: {{BASE}} · API key (write): {{M0_KEY}} · Tenant id: {{TENANT}}

A template is one JSON spec: {name?, width?=1200, height?=630, scale?=2,
html, params}. "html" is a Go html/template document; declared params are
available as {{.name}} and always interpolate as strings. Param spec:
{type: "string"|"number"|"image_url"|"data_url", default?, required?,
maxLen?, description?}. Give every param a description and a sensible
maxLen.

Model list-like data (chart values, rankings, labels) as ONE compact
comma-separated param and split it in the template:
{{range $i, $v := split .values ","}}…{{end}} — never one param per item.

Hard sandbox rules: external loads are https GET only, restricted to
these CDNs: cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com,
fonts.googleapis.com, fonts.gstatic.com (pin exact versions in URLs).
Every other host is blocked. Also available: inline CSS, data: URIs,
and the shared library at /lib (og.css utilities, grid.svg, blob.svg,
laurel.svg, seal.svg). Inline static artwork as base64 data: URIs
(html budget is 1 MB). External images must be declared as image_url
params; the service fetches them and hands your template a local
filename. External data (CSV/JSON) goes through data_url params: the
template receives the fetched content in place of the URL — explode it
with split. Chart.js/ECharts from the CDN allowlist work: disable
animations, init in an inline script, and if drawing continues past the
load event set window.m0ready to a Promise — the screenshot waits for
it. Renders have a 3-second budget; the viewport (width x height) is
the final image, so use one full-bleed container with overflow hidden
and fixed px typography, and design for the longest allowed param
values.

Proven starting points: GET {{BASE}}/library.json lists the public
template library (no auth); append .json to any library page URL for its
exact spec plus known-good sample_params — fork instead of starting blank.

Workflow — always in this order:
1. POST {{BASE}}/api/v1/templates/validate with {"spec": …} and iterate
   until it returns "ok": true. This is free and never renders.
2. Preview the draft with POST {{BASE}}/api/v1/templates/preview
   {"spec": …, "params": {…}} — returns the webp without publishing
   anything; iterate until it looks right. Changed previews consume
   render quota, unchanged repeats are free cache hits.
3. Create with POST {{BASE}}/api/v1/templates {"name": …, "spec": …}, or
   publish a revision with PUT {{BASE}}/api/v1/templates/{id} {"spec": …}.
4. Verify by fetching
   {{BASE}}/tenants/{{TENANT}}/image/t/{id}/{latest_version}/webp?<params>
   with representative params, and inspect the returned webp.
Reuse identical preview params between iterations (cache hits are free;
each new param combination costs quota). Report the template id, version
and a ready-to-use image URL when done.
All calls need "Authorization: Bearer {{M0_KEY}}" and JSON bodies; errors
come back as {"error": …} and validation issues as {"problems": […]}.

Reference#

Complete reference for every media0 API endpoint.

media0 exposes three planes:

  1. Image plane — public, unauthenticated, tenant-scoped. Serves rendered images (alias/latest URLs 302 to the canonical render URL).
  2. Management API — /api/v1/*, authenticated with a tenant API key (Authorization: Bearer m0_…) or a console session cookie (see Authentication & signup). Available on the base host and on tenant hosts (subdomain/CNAME); on a tenant host the key must belong to that host's tenant.
  3. Admin plane — /admin/*, base host only, operator-facing, authenticated with the admin token as a Bearer token. Disabled (404) when no admin token is configured.

Identity endpoints (/auth/*) live on the base host only, outside /api/v1, and use the same JSON error shape.

Errors, scopes, and hosts#

Errors are uniform JSON, {"error": "…"} (spec validation failures add "problems": ["…", …]):

Error HTTP
invalid input 400
unauthorized 401
not found 404
conflict 409
quota exceeded 429

Key scopes: read or write; write implies read. Write endpoints return 403 {"error": "write scope required"} for read keys.

Rate limits: the render endpoint, the draft-preview endpoint, all management-API write endpoints and the whole /admin plane are per-IP rate-limited (429 + Retry-After when exceeded). Behind the reverse proxy the limit applies to the resolved client IP, not the proxy.

Examples below use BASE=https://media0.eu and a tenant-scoped image base IMG which is any of:

https://media0.eu/tenants/{tenantID}   # path plane
https://{tenantID}.media0.eu           # subdomain plane
https://img.customer.com                    # CNAME plane (tenant-configured)

Image plane (public)#

GET {IMG}/image/t/{templateID}/{versionID}/webp?params#

The render endpoint — the only URL that ever renders. Everything else 302s here. templateID is a UUID, versionID a 6-char base36 id (000001…). Query params are validated against the version's declared params; undeclared params are ignored, defaults are applied, and a missing required param is a 400.

Responses:

Because the URL is immutable, any CDN in front of media0 caches it as an ordinary proxy.

curl -s -o card.webp \
  "$IMG/image/t/1b9f3c40-.../000002/webp?title=Hello&badge=New"

Cache-miss renders count against the monthly quota (429 when exhausted) and run under the 3 s render budget. The quota is a rate limit, not billing: failed renders consume it too. This endpoint is IP-rate-limited. Private templates additionally require a signature or an API key — see Render access: three modes.

GET {IMG}/image/t/{templateID}?params#

302 to the fully-qualified URL above using the template's latest version. Cache-Control: public, max-age=60, so new versions roll out within a minute. Params are preserved (canonically ordered).

curl -sIL "$IMG/image/t/1b9f3c40-...?title=Hello"

GET {IMG}/image/{alias}?params#

Resolves the tenant's alias → template → latest version; same 302 as above. Aliases match [A-Za-z0-9_-]{1,64} and are never UUID-shaped, so the two routes cannot collide.

curl -sL -o card.webp "$IMG/image/card?title=Hello"

Render access: three modes#

Every render endpoint (/image/…) serves a template in exactly one of three modes, decided by the template's visibility ("public", the default, or "private" — set at creation via the visibility body field or later with PUT /api/v1/templates/{id} {"visibility": "private"}):

Mode Template Request carries For
Public public nothing anyone renders any param combination
Private + signature private &m0-sig=<hex> handing URLs to browsers / third parties without sharing a key
Private + Authorization private Authorization: Bearer m0_… server-to-server fetches (any key of the owning tenant; read scope suffices)

A private request with neither proof — or a wrong one — is a 401.

Signing (offline, no API call). The tenant leads the message, the sorted params follow:

message  = "tenant={TENANT}" + "".join(sorted(f"{k}={v}" for every query param))
sign_key = sha256(api_key_secret).hexdigest()          # lowercase hex
m0-sig   = hmac_sha256(sign_key, message).hexdigest()  # lowercase hex
import hashlib, hmac
def sign(api_key: str, tenant: str, params: dict) -> str:
    msg = f"tenant={tenant}" + "".join(sorted(f"{k}={v}" for k, v in params.items()))
    key = hashlib.sha256(api_key.encode()).hexdigest()
    return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest()
# url = f"https://{tenant}.media0.eu/image/t/{tid}?{urlencode(params)}&m0-sig={sign(...)}"

Values enter the message raw (not URL-encoded), params are the exact query params you put in the URL (minus m0-sig itself), and the sign key is the hex SHA-256 of your API key — the raw key is never stored server-side, so it cannot be the HMAC key. The message contains no host and no template/version path: one signature works on every plane (path, subdomain, CNAME) and survives the latest-version 302. Any param change invalidates it.

Authentication & signup#

media0 has one identity per tenant — a tenant is the account; there is no multi-user. Two credentials exist:

All /auth endpoints are on the base host only, take/return JSON, and map errors exactly like the rest of the API ({"error": "…"}, 400/401/404/409).

For the step-by-step signup walkthroughs (browser signup, agent signup, claiming an agent-created account), see Access — this section documents the endpoints themselves.

Successful signup/finish, login/finish and recover respond with Set-Cookie:

Property Value
Name m0_session
Flags HttpOnly, SameSite=Lax, Path=/; Secure on https origins
Lifetime 30 days, sliding — activity prolongs it (touches are throttled to at most once per hour)

Only a hash of the session token is stored server-side.

Sessions on /api/v1: a valid cookie resolves the tenant and grants write scope — no Authorization header needed. CSRF hardening (on top of SameSite=Lax): cookie-authenticated non-GET requests to /api/v1 and /auth must be same-origin — when an Origin header is present it must match the site's public origin; Sec-Fetch-Site: same-origin or none is accepted. Cross-site writes are rejected.

Proof of work (agent signup gate)#

Agent signup is gated by a Hashcash-style proof of work instead of email verification or CAPTCHAs — the full walkthrough, including a solver example, is in Signup as AI. For reference: the server issues a random single-use challenge (64 hex chars) with a difficulty in leading zero bits (default 22, ~4M hashes — a couple of CPU-seconds) and a 10-minute TTL; the client finds an ASCII nonce (1–64 chars) such that sha256(challenge + ":" + nonce) has at least bits leading zero bits; the server verifies with a single hash.

Challenges are strictly single-use and are burned on failure: the first agent/signup attempt consumes the challenge whether or not the nonce (or the rest of the request) is valid. If the attempt fails, request a fresh challenge and re-solve.

POST /auth/pow/challenge#

No body. Rate-limited per IP. Returns 201:

{ "challenge": "9f2c…64 hex…", "bits": 22, "expires_at": "2026-08-05T12:10:00Z" }

POST /auth/agent/signup#

Creates a tenant in one authenticated-by-work call. Body:

{ "challenge": "…", "nonce": "…", "name": "my-bot", "email": "owner@example.com" }

name and email are optional; a provided email must be unused (409 otherwise). 400 for a bad/expired/used challenge or a failed proof of work. Returns 201:

{
  "tenant_id": "6f1d2e7a-…",
  "api_key": { "id": "…", "secret": "m0_…", "scope": "write", "prefix": "m0_ab12" },
  "recovery_codes": ["xxxxx-xxxxx", "… 10 codes …"],
  "claim_url": "https://media0.eu/app/#/claim?tenant=6f1d2e7a-…"
}

The key secret and the recovery codes are shown exactly once. The claim_url is a console page where a human redeems one recovery code to get a session, then registers passkeys (the code itself is never part of the URL) — see Human claim of an AI signup. No cookie is set by this endpoint.

POST /auth/signup/begin#

Browser signup, step 1. Body {"email": "…", "name": "…"?} — email is required here and must be unused (409). Returns {"flow_id": "…", "publicKey": {…}} where publicKey is a WebAuthn CredentialCreationOptions (binary fields base64url-encoded). The tenant is not created yet.

POST /auth/signup/finish#

Body {"flow_id": "…", "credential": {…attestation response JSON…}}. Creates the tenant, registers the passkey, sets the session cookie and returns 201:

{ "tenant_id": "…", "recovery_codes": ["… 10 codes, shown exactly once …"] }

POST /auth/login/begin#

Body {"email": "…"?}. With an email, the response's publicKey (CredentialRequestOptions) lists that account's credentials in allowCredentials; without one it is a discoverable/usernameless request — the authenticator picks the passkey. Returns {"flow_id": "…", "publicKey": {…}}.

POST /auth/login/finish#

Body {"flow_id": "…", "credential": {…assertion response JSON…}}. Returns {"tenant_id": "…"} and sets the session cookie.

POST /auth/recover#

Redeems one recovery code for a session — this is both the fallback login and the claim flow for agent-created accounts. Body:

{ "tenant_id": "…", "code": "xxxxx-xxxxx" }

(or "email" instead of "tenant_id"). The code is consumed on success. A wrong or already-used code is a 404. Returns {"tenant_id": "…"} and sets the session cookie.

GET /auth/me#

Session-authenticated (cookie m0_session), as are all endpoints below.

{ "tenant_id": "…", "email": "you@example.com",
  "passkeys": [ { "id": "…", "name": "MacBook Touch ID",
                  "created_at": "…", "last_used_at": "…" } ],
  "recovery_codes_left": 7 }

POST /auth/passkeys/begin#

Session-authenticated. No body required. Returns {"flow_id": "…", "publicKey": {…}} (creation options) for registering an additional passkey.

POST /auth/passkeys/finish#

Session-authenticated. Body {"flow_id": "…", "credential": {…}, "name": "…"?}. Returns 201 {"id": "…", "name": "…", "created_at": "…"}.

DELETE /auth/passkeys/{id}#

Session-authenticated. 204. Refused with 409 when it is the last passkey and no unused recovery codes remain — that would lock the account. Regenerate recovery codes (or add another passkey) first.

POST /auth/recovery/regenerate#

Session-authenticated. Returns {"recovery_codes": ["… 10 fresh codes …"]} — shown exactly once. All previous codes are invalidated.

PUT /auth/email#

Session-authenticated. Body {"email": "…"}; "" clears the address. 204; 409 when the address belongs to another tenant.

POST /auth/logout#

Session-authenticated. 204. Deletes the session server-side and clears the cookie.

Management API#

Authenticate with Authorization: Bearer m0_… or a session cookie. All examples: AUTH='Authorization: Bearer m0_…'.

GET /api/v1/templates (read)#

List all templates, each with its latest published spec.

curl -s $BASE/api/v1/templates -H "$AUTH"
{ "templates": [ { "id": "1b9f3c40-…", "tenant_id": "…", "name": "og-basic",
    "created_at": "…", "updated_at": "…", "latest_version": "000002",
    "spec": { "html": "…", "width": 1200, "height": 630, "scale": 2, "params": {…} } } ] }

POST /api/v1/templates (write)#

Create a template and publish version 000001. Body: {"name": "…"?, "visibility": "public"|"private"?, "spec": {…}} (name falls back to spec.name; visibility defaults to public — see Render access: three modes). Spec problems → 400 with problems; template quota → 429.

curl -s $BASE/api/v1/templates -H "$AUTH" -d '{
  "name": "og-basic",
  "spec": {
    "html": "<!doctype html><html><head><link rel=\"stylesheet\" href=\"/lib/og.css\"></head><body><div class=\"og-canvas og-grad-indigo\"><h1 class=\"og-title\">{{.title}}</h1></div></body></html>",
    "params": { "title": { "type": "string", "required": true, "maxLen": 120 } }
  }
}'

Returns 201 with the template object (including latest_version and the normalized spec).

GET /api/v1/templates/{id} (read)#

Single template with its latest spec. Unknown/foreign id → 404.

PUT /api/v1/templates/{id} (write)#

Body {"name": "…"?, "visibility": …?, "spec": {…}?}. When spec is present a new immutable version is published — latest-version URLs pick it up within a minute; name/visibility alone just update the template head. Returns the updated template + spec.

curl -s -X PUT $BASE/api/v1/templates/1b9f3c40-... -H "$AUTH" \
  -d '{"spec": {"html": "…", "params": {…}}}'

DELETE /api/v1/templates/{id} (write)#

Removes the template, all its versions and its aliases. 204.

GET /api/v1/templates/{id}/versions (read)#

curl -s $BASE/api/v1/templates/1b9f3c40-.../versions -H "$AUTH"
# {"versions": [{"template_id": "…", "version_id": "000002", "spec": {…}, "created_at": "…"}, …]}

GET /api/v1/templates/{id}/versions/{vid} (read)#

One immutable version snapshot.

POST /api/v1/templates/validate (read)#

Dry-run: parse + normalize a spec. Never renders, never persists, costs no quota — always returns 200:

curl -s $BASE/api/v1/templates/validate -H "$AUTH" \
  -d '{"spec": {"html": "<h1>{{.title}}</h1>"}}'
{ "ok": true, "problems": [],
  "spec": { "html": "…", "width": 1200, "height": 630, "scale": 2 } }

When invalid, ok is false and problems lists human/machine-readable issues (e.g. "html: does not parse as Go html/template: …").

POST /api/v1/templates/preview (read)#

Render a draft spec without publishing anything — see the spec in the image before spending a template slot or version. Body {"spec": {…}, "params": {"title": "Hello"}?}; params are validated against the spec exactly like a real render (missing required param → 400). Returns 200 image/webp (Cache-Control: no-store, X-Cache: HIT|MISS):

curl -s $BASE/api/v1/templates/preview -H "$AUTH" -o draft.webp \
  -d '{"spec": {"html": "<h1>{{.title}}</h1>", "params": {"title": {"required": true}}},
       "params": {"title": "Hello"}}'

Spec problems → 400 with problems (same as create/publish). Nothing is persisted: results live in a short in-memory cache only. An unchanged spec+params combination is a free cache hit; every changed preview is a real render and consumes the monthly render quota (429 when exhausted). IP-rate-limited like the image plane. Read scope suffices.

GET /api/v1/aliases (read)#

{ "aliases": [ { "tenant_id": "…", "name": "card",
    "template_id": "1b9f3c40-…", "created_at": "…", "updated_at": "…" } ] }

PUT /api/v1/aliases/{name} (write)#

Create or repoint (upsert). Body {"template_id": "<uuid>"}; the template must belong to the same tenant. Name rules: [A-Za-z0-9_-]{1,64}, not UUID-shaped, not a reserved word (t, lib, app, api, admin, tenants, image, assets).

curl -s -X PUT $BASE/api/v1/aliases/card -H "$AUTH" \
  -d '{"template_id": "1b9f3c40-..."}'

DELETE /api/v1/aliases/{name} (write)#

Returns 204.

GET /api/v1/stats?month=2026-08 (read)#

month is optional (defaults to the current UTC month), format YYYY-MM. Returns the tenant's usage stats:

{ "tenant_id": "…", "month": "2026-08",
  "renders": 412, "render_limit": 10000,
  "requests": 51830, "bytes_served": 1734082560,
  "templates": 7, "template_limit": 50 }

GET /api/v1/config (read)#

{ "id": "6f1d2e7a-…", "name": "acme", "cname_domains": ["img.customer.com"],
  "max_templates": 50, "monthly_render_limit": 10000,
  "image_retention_seconds": 86400 }

image_retention_seconds is how long rendered images stay cached after their last access (platform-managed, default 24 h; every access slides the window).

PUT /api/v1/config (write)#

Body {"name": "…"?, "cname_domains": ["…"]?} — omitted fields are unchanged; cname_domains replaces the whole list. Domains must be valid lowercase hostnames and must not be under the platform host.

curl -s -X PUT $BASE/api/v1/config -H "$AUTH" \
  -d '{"cname_domains": ["img.customer.com"]}'

GET /api/v1/keys (read)#

Lists keys without secrets — only the display prefix:

{ "keys": [ { "id": "…", "tenant_id": "…", "name": "ci-deploy",
    "prefix": "m0_ab12", "scope": "write",
    "created_at": "…", "last_used_at": "…" } ] }

POST /api/v1/keys (write)#

Body {"name": "…", "scope": "read"|"write"}. The secret is returned exactly once and only a hash is stored:

curl -s $BASE/api/v1/keys -H "$AUTH" -d '{"name": "ci", "scope": "read"}'
# 201 {"key": {…, "prefix": "m0_cd34", "scope": "read"}, "secret": "m0_cd34…"}

DELETE /api/v1/keys/{id} (write)#

Returns 204.

GET /api/v1/whoami (read)#

Reports how the request authenticated. With an API key:

{ "tenant_id": "6f1d2e7a-…", "auth": "key", "key_id": "…", "scope": "write" }

With a session cookie (key_id/scope are omitted — sessions always have write scope):

{ "tenant_id": "6f1d2e7a-…", "auth": "session" }

Admin plane (operator-facing)#

Base host only, authenticated with Authorization: Bearer <admin token>. These endpoints are for the platform operator, not tenants — documented here for completeness.

POST /admin/tenants#

Body {"name": "…"}. Creates the tenant plus an initial write key:

curl -s $BASE/admin/tenants -H "Authorization: Bearer $MEDIA0_ADMIN_TOKEN" \
  -d '{"name": "acme"}'
# 201 {"tenant": {…}, "initial_key": {"key": {…}, "secret": "m0_…"}}  # secret shown once

GET /admin/tenants#

{ "tenants": [ { "id": "…", "name": "acme", "created_at": "…",
    "cname_domains": null, "max_templates": 0, "monthly_render_limit": 0 } ] }

(0 limits mean platform defaults.)

DELETE /admin/tenants/{id}#

Returns 204.

Web routes (base host only)#

Route Serves
GET / landing page
GET /app/ tenant console (SPA; passkey / recovery-code / API-key sign-in, signup at #/signup, claim at #/claim)
GET /library public template library (HTML, by category)
GET /library.json machine-readable library index: every template with its page and raw-spec json URL
GET /library/{category}/{id}/{slug}.json raw template JSON (description, sample_params, full spec) adjacent to each detail page; GET /library/t/{id}.json 301s here
/auth/* identity endpoints (see Authentication & signup)
GET /robots.txt User-agent: * / Disallow: /tenants/

Tenant hosts (subdomain/CNAME) serve only the image plane, /api/v1 and an allow-all robots.txt (empty Disallow:) — tenant hosts are crawlable; only the base host's /tenants/ path plane is kept out of indexes (robots rule above plus X-Robots-Tag: noindex).