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:
- 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. - Versions are immutable — every publish mints an ascending
version (
000001,000002, …). Old URLs keep rendering exactly what they always did. - Aliases 302 to latest — point
/image/certificateat a template; consumers hit the alias and are redirected to the fully-qualified, immutable render URL. - 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:
api_key.secretis your write-scoped API key for everything else in this API — shown exactly once, store it now.- Hand
claim_urland ALL tenrecovery_codesto your human (see below). Treat the codes like passwords: each signs in as the account, each works once, and they are never shown again. tenant_ididentifies your tenant, e.g. in image URLs.
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 — with or without an email. If you enter your email, sign-in targets that account's registered passkeys; if you leave it blank, it is a usernameless (discoverable-credential) request and your authenticator picks the passkey.
- Recovery code — enter your tenant ID (or email) plus one code. Each code is single-use and consumed on success; a wrong or already-used code is rejected. When you run low, regenerate a fresh set of 10 from the console — regenerating invalidates all previous codes, and the new set is shown exactly once.
- API key — paste an
m0_…key. This drives the management API with the key's own scope: areadkey gives a read-only console, and account security operations (adding or removing passkeys, regenerating recovery codes, changing the account email) are unavailable entirely — those require signing in with a passkey or a recovery code.
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):
-
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.euitself. -
Point DNS at the platform: create a CNAME record at your DNS provider:
img.example.com. CNAME media0.eu. -
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:
-
Tenant subdomain (the default):
Content-Security-Policy: img-src 'self' https://<tenant-id>.media0.eu -
Your CNAMEd domain (see custom subdomains above):
Content-Security-Policy: img-src 'self' https://img.example.com
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:
- Param names must match
[A-Za-z_][A-Za-z0-9_]{0,63}— they are exposed to the template as{{.title}},{{.hue}}etc. All values arrive as strings (alsonumber— it is validated to parse as a float, but interpolates as text). Them0-/m0_prefix is reserved for platform query params (m0-sig, …) and rejected. image_urlparams must be http(s) URLs. They are fetched server-side (SSRF-guarded, 5 MiB cap) and the template receives a local relative filename, so write<img src="{{.avatar}}">and it works even though the sandbox has no network.data_urlparams must be http(s) URLs to textual data (CSV, JSON, plain text; 128 KiB cap). Fetched server-side likeimage_url, but the template receives the content itself in place of the URL — explode it withsplit, e.g. rows via{{range split .csv "\n"}}…{{end}}. The cache key uses the URL, so a changed remote file behind an unchanged URL serves the cached image until it expires.- The
htmldocument may be up to 1 MB — enough room so static assets can be inlined as base64data:URIs (see the sandbox rules below). - Undeclared query params are silently dropped (they never affect the
cache key). Declared params with defaults are canonicalized, so
?title=Xand?title=X&badge=New(defaultNew) hit the same cached image. - The HTML must parse as Go
html/template. Contextual auto-escaping applies:{{.x}}in text/attributes is escaped for you; in astyleattribute only safe CSS values pass (plain numbers are fine — that is howstyle='--h: {{.hue}}'works). - Template functions: besides the
html/templatebuilt-ins (index,printf,len, …),splitexplodes a string into a list (split .values ",") andhasPrefixtests string prefixes — together they cover word-level rendering like highlighting @mentions/#hashtags/links:{{range split .text " "}}{{if hasPrefix . "@"}}<b>{{.}}</b>{{else}}{{.}}{{end}} {{end}}.
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">
- One GET, one webp. The first open renders it; every later open — same params — is a free cache hit, for every recipient.
- Crisp in every client. Templates render 1200×630 at 2× scale; emails display at ~600 px content width, so the image is retina-sharp exactly where it lands.
- Nothing to attach, nothing stale. Change the params, get a new immutable image; yesterday's emails keep showing yesterday's chart.
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.
Start from the library (optional but recommended)#
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:
- External URLs are allowlisted. Same-origin relative paths and
data:URIs always load, plus httpsGETs to these static-asset CDNs:cdn.jsdelivr.net,unpkg.com,cdnjs.cloudflare.com,fonts.googleapis.com,fonts.gstatic.com. Every other host (and any non-GET request) is blocked — includingcdn.tailwindcss.com. Pin exact versions in CDN URLs (…/npm/chart.js@4.4.9/…) so a render today equals a render next year. - JS chart libraries work. Load Chart.js or ECharts from the
allowlist, disable animations, and draw from an inline
<script>(allowed by CSP). If the page finishes drawing asynchronously — fetched map data, anything after theloadevent — assignwindow.m0ready = <Promise>; the screenshot waits for it to settle. The Email Charts library category is built exactly this way: fork one of those templates instead of wiring a chart from scratch. - Static images and backgrounds: inline them as base64
data:URIs. This is THE way to ship fixed artwork with a template —<img src="data:image/webp;base64,…">orbackground: url('data:image/png;base64,…')— and the 1 MB html budget leaves room for it (base64 costs ~4/3 of the raw bytes; prefer webp/avif sources). Dynamic assets are fetched at render time instead: external images viaimage_urlparams, external data (CSV/JSON) viadata_urlparams — both SSRF-guarded. /lib/<file>assets are available — the shared library is available to every render:/lib/og.css— reset +.og-canvas,.og-title,.og-subtitle,.og-badge,.og-footer,.og-kicker, gradient utilities (.og-grad-indigo|sunset|emerald|slate|paperand parametric.og-grad-huedriven by--h),.og-bg-grid, andcert-*classes for certificates (ornamental border, seal/laurel slots)./lib/grid.svg(tileable dot grid),/lib/blob.svg(gradient blob),/lib/laurel.svg,/lib/seal.svg(certificate ornaments).
- External images go through
image_urlparams, fetched server-side with SSRF guards; the template sees a local filename. - 3 second budget per render, total (load + fonts + scripts +
screenshot), including cold CDN fetches — warm renders reuse
Chrome's cache. No long animations, no
setTimeoutchoreography; the screenshot is taken as soon as the body is ready, fonts are loaded andwindow.m0ready(when set) has resolved. - Precompiled CSS only. Inline
<style>, style attributes, or/lib/og.css. If you want Tailwind, compile it to a static sheet yourself and inline it — the Tailwind CDN script is blocked. - System font stacks, Google Fonts, or
data:URI@font-face. The renderer waits fordocument.fonts.ready. - Output is always webp; the only network JS can talk to is the CDN allowlist above, so keep everything else in the document.
Tips for deterministic 1200×630 layouts#
- The viewport is the image: build one full-bleed root,
.og-canvas(100vw × 100vh, flex column,overflow: hidden), and never rely on scrolling — anything outside the viewport is cropped. - Use fixed px sizes, not viewport or rem tricks: 76px titles, 34px
subtitles read well at 1200×630 (see the
.og-*type scale). - Cap text: set
maxLenon params AND design for the worst case (overflow-wrap: break-word; consider-webkit-line-clampfor hard clamps). A 120-char title must not push the footer off-canvas. - Prefer
justify-content: space-betweencolumns over absolute positioning; give absolutely-positioned backdropsz-index: 0and contentz-index: 1. - Keep color deterministic: the renderer forces sRGB; avoid
prefers-color-schemein template CSS (the headless browser's theme is not your consumer's). - The canvas is transparent: whatever your CSS does not paint stays
transparent in the output webp (alpha is preserved), it is not
flattened onto white. Paint
body{background:…}for opaque cards — or leave it transparent on purpose for floating cards, badges and stickers that sit on the consumer's own backdrop. - Test the empty/default case: every optional param renders as its default (or empty string if the param has no default and is absent) — make sure the layout survives it.
scale: 2doubles the bitmap (2400×1260) for crisp text at the same layout coordinates — keep it unless bytes matter more than sharpness.
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:
- Image plane — public, unauthenticated, tenant-scoped. Serves rendered images (alias/latest URLs 302 to the canonical render URL).
- 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. - 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:
200 image/webpwithCache-Control: public, max-age=86400, immutable,ETag: "<cache-key>"andX-Cache: HIT|MISS(supportsIf-None-Match→304).
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:
- API keys (
m0_…,read/writescope) — for programs. Managed under/api/v1/keys. - Console sessions — for humans in the browser, established with a passkey (WebAuthn) or a single-use recovery code. A session is an HttpOnly cookie and authenticates
/api/v1/*with full write scope, plus the session-only account-management endpoints below.
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.
Session cookie#
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).