Docs
The avatar API
One origin serves every avatar: a document in, a versioned 3D head and a 2D picture out. Plain HTTPS and JSON — no SDK required, though there is a Swift package. This page is the contract: the origin, the keys, the routes, what moves a URL, the errors.
Quick start
Two ways in. A server holds a secret key and saves documents; an app holds a publishable key and plays heads. Get either at API keys.
From a server, with a secret key
curl -X POST https://striped-walrus-538.convex.site/avatars \
-H "Authorization: Bearer mk_sec_…" \
-H "content-type: application/json" \
-d '{"doc":{"seed":4821,"head":"round","skin":"brown",
"hair":{"style":"afro","color":"ink"}}}'
# → {"avatarKey":"5dd7…", "glb":"…/avatars/5dd7….glb", "png":"…/avatars/5dd7….png"}
<img src="https://striped-walrus-538.convex.site/avatars/<avatarKey>.png?size=128">In a SwiftUI app, with a publishable key
// Package: github.com/hypersocialinc/MinimojiKit (iOS 18) import MinimojiMetal import MinimojiService let service = AvatarService(base: URL(string: "https://striped-walrus-538.convex.site")!, key: "mk_pub_…") let head = try await service.head(for: ["seed": 4821, "head": "round", "skin": "brown"]) MinimojiMetalHeadView(document: head, expression: "happy").frame(width: 96, height: 96) AsyncImage(url: service.renderURL(for: avatarKey, size: 256))
The origin
Everything below is served from one origin, shown on your
API keys page (production is
https://striped-walrus-538.convex.site). The objects the routes redirect
to live on https://bakes.minimoji.com; you never construct those URLs
yourself, you follow the redirect or store what a response gave you.
Keys
The write routes want Authorization: Bearer <API key>, minted at
minimoji.com/developers/keys.
Two kinds, the Stripe shape:
| kind | lives | may |
|---|---|---|
publishable mk_pub_… | in an app | bake heads (POST /avatars/bake) and cut editor pieces (POST /packs/category): idempotent, content-addressed, rate-limited |
secret mk_sec_… | on your server | all of that, plus save avatars (POST /avatars) and upload pictures (POST /renders/put) |
The reads (.json, .glb, .png) take no key: they are URLs that go into
<img> tags. A key is shown once when it is created and can be revoked at
any time. Limits per key, per minute: 60 bakes, 20 cuts, 120 saves, 60
uploads; a 429 says how long to wait.
The routes
| route | in | out |
|---|---|---|
POST /avatars | {doc} | 200 {avatarKey, json, glb, png} — the document normalized and hashed; the same document is always the same key. Secret key |
GET /avatars/<key>.json | 200 the normalized document, Cache-Control: immutable | |
GET /avatars/<key>.glb?detail=.4&lite=0 | 302 to the baked head, baking on the first request anywhere (seconds); the redirect is cacheable for a day | |
GET /avatars/<key>.png?size=256 | 302 to the picture once drawn; the first request draws it (a few seconds) and answers with the bytes. size is 64, 128, 256 or 512 | |
POST /avatars/bake | {doc, detail?, lite?} | 200 {url, bakeKey, bytes, cached} — a document and no key yet: an editor's path. Publishable or secret key |
POST /packs/category | {doc, current?, field, detail?} | 200 {status, url, partsBase, …} — one category's pieces for an editor composing locally. Publishable or secret key |
POST /renders/put?avatarKey=&size= | PNG bytes | 200 {url, renderKey, bytes, cached} — a picture you drew yourself: a size×size PNG under 2 MB for a saved avatar; the first upload for a key wins. Secret key |
Every route answers OPTIONS and sends Access-Control-Allow-Origin: *.
Query values: detail is a fraction of full geometry in (0, 1] (absent: .4,
the mobile budget); lite is 0|1|true|false and drops the effect plates.
Errors
Always {error} with words, never a bare status: 400 for a value the
grammar refuses, 401 for a missing or unknown key (the message names the
keys page), 403 for a key of the wrong kind (it names the kind wanted),
404 for a key nobody saved, 429 for a rate limit (with the wait), 500
with the message when a bake throws.
What moves a URL
avatarKey = sha256hex(canonical(normalize(doc))) 64 hex
bakeKey = <avatarKey>-v<version>-d<round(detail*100)>[-lite]
renderKey = <avatarKey>-v<version>-s<size>
The document after normalization is the identity: field order and
omitted defaults do not matter, and an old spelling the normalizer rewrites
lands on the same key. A different value — a new hairstyle, another eye
colour — is a new key. version is the engine's bake version: when the
service changes how an existing head is drawn it bumps the version and
every .glb and .png URL moves at once, which is why you store the
avatar key (or the contract URL, which always redirects to the current
version) rather than a CDN URL. detail, lite and size are the other
dimensions a URL has.
Two examples
S=https://striped-walrus-538.convex.site
DOC='{"v":1,"seed":4821,"head":"round","skin":"brown","hair":{"style":"afro","color":"ink"}}'
# save, then fetch the head: the same document always lands on the same object
KEY=$(curl -s -X POST $S/avatars -H "Authorization: Bearer $MINIMOJI_SECRET_KEY" \
-H 'content-type: application/json' -d "{\"doc\":$DOC}" | jq -r .avatarKey)
curl -sIL "$S/avatars/$KEY.glb?detail=0.4" | grep -iE '^(HTTP|location)'
# HTTP/2 302 … location: https://bakes.minimoji.com/bakes/<KEY>-v2-d40.glb
# HTTP/2 200 (1.9 MB, model/gltf-binary)
# its picture, for a list row or an invite page: no key at all
curl -sIL "$S/avatars/$KEY.png?size=256" | grep -iE '^(HTTP|location|content-type)'
# the first time: HTTP/2 302 … location: https://www.minimoji.com/api/render/<KEY>?size=256
# HTTP/2 200 content-type: image/png (drawn now, a few seconds)
# from then on: HTTP/2 302 … location: https://bakes.minimoji.com/renders/<KEY>-v2-s256.png
# HTTP/2 200 content-type: image/png
The Swift side of the same two calls is the quick start in
packages/swift/MinimojiKit/README.md: AvatarService(base:key:),
head(for:), save(_:), renderURL(for:size:).