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:

kindlivesmay
publishable mk_pub_…in an appbake heads (POST /avatars/bake) and cut editor pieces (POST /packs/category): idempotent, content-addressed, rate-limited
secret mk_sec_…on your serverall 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

routeinout
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>.json200 the normalized document, Cache-Control: immutable
GET /avatars/<key>.glb?detail=.4&lite=0302 to the baked head, baking on the first request anywhere (seconds); the redirect is cacheable for a day
GET /avatars/<key>.png?size=256302 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 bytes200 {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:).