LowPolyAvatar API

Create, customise and serve 3D avatars for any game, world or app. Avatars are assembled from a modular asset catalog, optimised into a single GLB, and served at a stable URL you can load in three.js, Unity, Unreal, Babylon or Godot.

Two ways to integrate:

ApproachUse it when
API-onlyYour app builds avatars programmatically (create recipe → build → load the GLB). You never need our UI.
Embedded WardrobeYou want your end-users to customise their own avatar in a drop-in editor — no 3D code on your side.

Base URL: http://localhost:8787. All responses are JSON with a { data } envelope; errors use { error: { code, message } }.

Authentication

Every API request carries an x-api-key header. Create keys per application in the Developer Console. There are two key types:

KeyPrefixWhereCan do
secretsk_Server onlyEverything: create / update / build / delete, mint edit tokens
publishablepk_Browser-safeRead assets & avatars, refresh edit tokens, wardrobe save
Never expose an sk_ key in a browser or mobile app. Use it server-side; hand the browser a publishable key + a short-lived edit token (see Edit tokens).
Optionally pin your sk_ key to an IP allowlist (exact IPs or IPv4 CIDR) in Console → app Settings. When set, secret-key requests from other IPs are rejected with 403 ip_not_allowed; empty = allow any.
curl https://api.lowpolyavatar.dev/v1/assets/categories?gender=male \
  -H "x-api-key: sk_test_xxx"

Try it — playground

Runs against this API host. Paste a key from your Console → Keys (use a test key). pk_ works for reads; sk_ is required for create / build / update / delete.

Response appears here…

Quickstart

Build a playable avatar in four calls:

BASE=http://localhost:8787 ; KEY=sk_test_xxx

# 1) pick parts from the catalog
curl "$BASE/v1/assets?gender=male&category=Body" -H "x-api-key: $KEY"

# 2) create an avatar with a recipe (slot → assetId)
AV=$(curl -s "$BASE/v1/avatars" -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"gender":"male","externalUserRef":"player-42","recipe":{
        "Body":"glb/Adult/Adlult_Male/Adult_Male_Body_01.glb",
        "Shirt":"glb/Adult/Adlult_Male/Adult_Male_Cook_Shirt.glb",
        "Pants":"glb/Adult/Adlult_Male/Adult_Male_Pants_01.glb",
        "Shoes":"glb/Adult/Adlult_Male/Adult_Male_Shoes_01.glb"}}' | jq -r .data.id)

# 3) build → optimised GLB at a stable URL
curl "$BASE/v1/avatars/$AV/build" -X POST -H "x-api-key: $KEY"

# 4) load it anywhere
echo "$BASE/v1/avatars/$AV/glb"   # 302 → the current GLB

JavaScript SDK

@lowpolyavatar/sdk wraps the API for Node and the browser.

import { LowPolyAvatar, mountWardrobe } from "@lowpolyavatar/sdk";

const ag = new LowPolyAvatar({ apiKey: "sk_test_xxx", apiBase: "http://localhost:8787" });

const cats   = await ag.listCategories("male");
const shirts = await ag.listAssets("male", "Shirt");
const avatar = await ag.createAvatar({ gender: "male", externalUserRef: "player-42",
  recipe: { Body: cats /*…*/ } });
const build  = await ag.build(avatar.id);     // → { url, drawsAfter, sizeOut }
const url    = ag.glbUrl(avatar.id);          // stable GLB URL

// Embed the wardrobe for an end-user (browser, publishable key + edit token):
const { token } = await ag.mintEditToken(avatar.id);  // mint server-side!
mountWardrobe(document.getElementById("wardrobe"), {
  publishableKey: "pk_test_xxx", editToken: token, avatarId: avatar.id,
  onSaved: (b) => console.log("new GLB", b.url),
});

List categories

GET/v1/assets/categoriespk_ / sk_

Returns the wearable slots for a gender with counts.

QueryTypeNotes
genderstringmale | female
{ "data": [ { "slot": "Hat", "count": 29 }, { "slot": "Shirt", "count": 23 }, … ] }

List assets

GET/v1/assetspk_ / sk_

Without query params, returns the full catalog grouped by gender → category, plus a branding object (your app's saved Wardrobe theme). With gender (and optional category) returns a flat array of assets instead.

Every asset carries pooledtrue when it sits in a pool (locked by default; needs a per-avatar unlock before it can be worn). Owned + enabled premium/epic assets are merged in too, additionally flagged premium:true.

pooled is app-level (“this asset needs an unlock”). To tell whether a specific avatar may wear it, combine it with that avatar's unlocks: locked = pooled:true and the id is not in GET /v1/avatars/:id/unlocks.

QueryNotes
gendermale | female
categorye.g. Hat (optional)
{ "data": [ {
  "id": "glb/Adult/Adlult_Male/Adult_Male_Hat_01.glb",   // use this in a recipe
  "name": "Hat_01", "slot": "Hat", "gender": "male",
  "url": "/assets/glb/Adult/Adlult_Male/Adult_Male_Hat_01.glb",
  "thumbnail": "/assets/thumbnails/…webp",
  "pooled": false,                                       // true → in a pool, needs a per-avatar unlock
  "draws": 1, "tris": 420, "size": 18342
} ] }

List animations

GET/v1/animationspk_ / sk_

The clip library. Clips drive the 44-bone humanoid skeleton; they apply to rigged avatars and are played at runtime by the SDK/viewer. The set is platform-configurable, so custom clips may appear alongside the built-ins.

{ "data": [ { "id":"walk","name":"Walk","type":"mixamo","loop":true,"inPlace":true,"file":"Walking.fbx" }, … ] }
GET/v1/animations.glbpk_ / sk_

Every enabled clip merged into a single binary glTF: one shared humanoid skeleton + each clip as a named animation. Load this one file instead of fetching the list and each clip separately. Revalidated by ETag — it updates automatically whenever the app's enabled clips change.

GET /v1/animations.glb   →   model/gltf-binary   (one GLB, N named animations)

Webhooks

Set a webhook URL for your app (Console → App → Settings). LowPolyAvatar POSTs a JSON event to it on avatar lifecycle changes, signed with HMAC‑SHA256 so you can verify authenticity.

Events: avatar.created, avatar.updated, avatar.built.

POST {your webhook URL}
x-lowpolyavatar-event: avatar.built
x-lowpolyavatar-signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your signing secret>
content-type: application/json

{ "id": "<delivery uuid>", "event": "avatar.built", "created": 1720000000000, "data": { … } }

Delivery is at-most-once. One POST per event, 5-second timeout, no retry and no ordering guarantee — treat webhooks as a hint and reconcile with GET /v1/avatars/:id if you need certainty.

Verify: compute HMAC_SHA256(secret, rawBody) and compare (constant‑time) to the hex after sha256=. The signing secret is shown once when you set the URL; saving a new URL keeps the same secret, and clearing the URL disables delivery.

Create avatar

POST/v1/avatarssk_
BodyTypeNotes
genderstringmale | female
recipeobjectmap of slot → assetId
externalUserRefstring?your user id (avatars are scoped per app + ref)
name, metadatastring?, object?your own fields
201 Created
{ "data": { "id":"…","gender":"male","recipe":{…},"status":"draft","currentBuildId":null,
            "portraitUrl":"https://…/portraits/….webp" } }

portraitUrl is rendered server-side on create; it is null if the render failed (never fatal).

Create random avatar

POST/v1/avatars/randomsk_

Generate a randomised avatar. Every result has a complete base outfit — Body, Face, Hair, Shirt, Pants, Shoes — plus a few random accessories (Glasses, Beard, Mustache, Gloves, Mask, Belt, Bracelet, Necklace, Chain, Watch, each ~40%). Honours the layering rules: no conflicting items are added (no Hat over Hair, no extra top over the Shirt, no extra bottom over the Pants). Picks only from assets enabled for your app, and excludes pooled (locked‑by‑default) assets — a new avatar has no unlocks, so random never equips an item it couldn't build. Creates a draft (1 credit) — call build to render the GLB.

Body (optional)TypeNotes
genderstring?male | female (random if omitted)
name, externalUserRef, metadatastring?, string?, object?same as create
201 Created
{ "data": { "id":"…","gender":"female","recipe":{ "Body":"…","Face":"…","Hair":"…","Shirt":"…","Pants":"…","Shoes":"…","Glasses":"…" },
            "status":"draft","currentBuildId":null,"portraitUrl":"https://…/portraits/….webp" } }

Returns 422 insufficient_assets if a required slot has no enabled, non‑pooled assets for the app.

Get avatar

GET/v1/avatars/:idpk_ / sk_

The raw avatar record (recipe, status, current build id).

Get config

GET/v1/avatars/:id/configpk_ / sk_

The resolved current config: each slot with its full asset detail, the rig type, and the current GLB. This is what a game client reads to render or equip the avatar.

{ "data": {
  "avatarId":"…","gender":"male","rig":"humanoid-static","status":"published",
  "recipe":{ "Body":"…","Shirt":"…" },
  "slots":[ { "slot":"Body","assetId":"…","asset":{ "name":"Body_01","url":"…","thumbnail":"…" } } ],
  "glbUrl":"http://localhost:8787/builds/….glb"
} }

List avatars

GET/v1/avatarspk_ / sk_

All avatars for your app, newest first.

QueryTypeNotes
externalUserRefstring?filter to one of your users, e.g. player-42
searchstring?match on name / user ref
limitnumber?default 50
offsetnumber?default 0

Update avatar

PATCH/v1/avatars/:idsk_

Change the recipe, name or metadata. Re-build to produce a new GLB.

Delete avatar

DELETE/v1/avatars/:idsk_

Removes the avatar and its builds.

Version history

GET/v1/avatars/:id/versionspk_ / sk_

Each publish saves a revertable version (recipe + build + portrait).

{ "data": [ { "id":"…","version":3,"recipe":{…},"gender":"male","name":"…","buildId":"…","glbUrl":"…","portraitUrl":"…","createdAt":1720000000000,"isCurrent":true }, … ] }
POST/v1/avatars/:id/versions/:versionId/revertsk_

Restore a previous version as the current one (its recipe, build and portrait).

Build (merge → GLB)

POST/v1/avatars/:id/buildsk_

Assembles the recipe's parts, merges by material, compresses textures (WebP) and geometry (Draco) → one optimised GLB at a stable URL. Identical recipes are de-duplicated (returns the existing build with reused:true).

Body (optional)Notes
animationsarray of clip ids to associate (see animations). Unknown ids are dropped.
{ "data": {
  "id":"…","status":"completed","url":"http://localhost:8787/builds/….glb",
  "drawsBefore":6,"drawsAfter":3,"sizeOut":52160,"animations":[],"animationMode":"none"
}, "reused": false }

List builds

GET/v1/avatars/:id/buildspk_ / sk_

Build history for the avatar (newest first).

Get current GLB

GET/v1/avatars/:id/glbpk_ / sk_

Convenience: 302-redirects to the avatar's current GLB. If no build exists yet, it builds one on demand. Point your loader straight at this URL.

const gltf = await new GLTFLoader().loadAsync(
  `http://localhost:8787/v1/avatars/${id}/glb?` + new URLSearchParams({}), // send x-api-key via fetch loader
);

Save a portrait (thumbnail)

POST/v1/avatars/:id/portraitsk_

Set the avatar's thumbnail (portraitUrl) yourself. Create, random and a recipe update already render one server-side and return it on the response — POST here only to override that with your own capture (e.g. a face crop from your game camera). Build does not re-render one. Keyed to the current build, so each published version keeps its own face.

BodyNotes
b64base64-encoded webp image (required)
{ "data": { "portraitUrl": "https://…/portraits/….webp" } }

Asset pools & unlocks

Group assets into pools in Console → app → Pools (an app can have many). Every asset in a pool is locked by default: an avatar can't wear (publish) it until you unlock it for that specific avatar. A build whose recipe contains a locked asset the avatar hasn't unlocked returns 403 assets_locked with info.locked (the offending recipe ids). Unlock after a purchase, achievement, level-up, etc.

Asset ids are recipe ids: free assets use their catalog path (e.g. glb/Adult/…/Hat.glb), premium/epic use premium/<uuid>.glb.
GET/v1/poolspk_ / sk_

List this app's pools and their asset ids. Only enabled assets are returned — a disabled free asset or inactive premium/epic is omitted, so it is never offered to unlock.

{ "data": [ { "id":"…", "name":"Season 1", "items":[ "glb/Adult/…/Hat.glb", "premium/<uuid>.glb" ] } ] }
GET/v1/avatars/:id/unlockspk_ / sk_

The asset ids currently unlocked for this avatar.

{ "data": [ "premium/<uuid>.glb" ] }
POST/v1/avatars/:id/unlockssk_

Unlock one or many pooled assets for this avatar so its user can wear them. Idempotent — re-unlocking is a no-op.

BodyNotes
assetIdsarray of recipe ids — unlock many at once
assetIda single recipe id — alternative to assetIds
curl "$BASE/v1/avatars/$AV/unlocks" -X POST -H "x-api-key: $KEY" \
  -H "content-type: application/json" \
  -d '{ "assetIds": ["glb/Adult/…/Hat.glb", "premium/<uuid>.glb"] }'

{ "data": { "avatarId":"…", "unlocked":[ "glb/Adult/…/Hat.glb", "premium/<uuid>.glb" ] } }
DELETE/v1/avatars/:id/unlockssk_

Re-lock assets for this avatar. Same body shape (assetIds or assetId).

{ "data": { "avatarId":"…", "relocked":[ "premium/<uuid>.glb" ] } }
GET/v1/avatars/:id/unlocks/newpk_ / sk_

Unlocks this avatar's user has not been shown yet. Each item is returned once and then marked seen, so you can drive a “new item!” badge without tracking state yourself.

Reading this endpoint changes state. It marks the returned items seen, and that queue is shared with the embedded Wardrobe — which announces new unlocks on entry. Call it only from the surface that actually shows the announcement, or your users will stop seeing it there.
{ "data": [ { "id":"premium/<uuid>.glb", "name":"Crown", "thumbnail":"/premium/thumbnails/….webp" } ] }

Chest rewards

Grant a random item the avatar does not own yet from one of your pools — the loot-box primitive behind daily chests, level-ups and quest rewards. A grant is an ordinary unlock, so the item appears in GET /v1/avatars/:id/unlocks straight after.

GET/v1/pools/:poolId/summarypk_ / sk_

How many enabled items the pool holds. Pair it with the avatar's unlocks to show “7 / 20 collected”.

{ "data": { "poolId": "…", "total": 20 } }
POST/v1/avatars/:id/chest-grantsk_

Unlocks one random un-owned item from the pool for this avatar.

BodyTypeNotes
poolIdstringrequired
idempotencyKeystring?a replay with the same key returns the same grant instead of drawing again — e.g. daily-2026-07-27-player-42
{ "data": {
  "granted": { "id":"premium/<uuid>.glb", "name":"Crown", "thumbnail":"…", "category":"Hat", "gender":"male" },
  "exhausted": false,
  "poolTotal": 20
} }

exhausted:true with granted:null means the avatar already owns every enabled item in the pool. Unknown pool, or a pool belonging to another app, returns 404 not_found.

Embed the Wardrobe

Drop a customisation UI into your app with no 3D code. Flow:

  1. Server-side, mint a short-lived edit token for the avatar (sk_).
  2. Open the Wardrobe with your publishable key + the token.
  3. Listen for the lowpolyavatar:saved message to get the new GLB URL.
  4. Optional — append &close=1 to show a in the Wardrobe header. It posts lowpolyavatar:close when clicked and lowpolyavatar:ready once the header is visible, so your host can show a fallback close control until ready arrives, then hide it.
import { mountWardrobe } from "@lowpolyavatar/sdk";
mountWardrobe(el, { publishableKey:"pk_…", editToken, avatarId, gender:"male",
  onSaved: (build) => loadIntoGame(build.url) });

Or embed the iframe directly: {WEB}/#/wardrobe?api={API}&pk=pk_…&token=…&avatarId=…&gender=male — append &close=1 for an in-header close button.

Edit tokens & wardrobe calls

POST/v1/avatars/:id/edit-tokensk_

Mint a 15-minute, single-avatar edit token (server-to-server). Hand it to the browser/Wardrobe.

{ "data": { "token":"eyJ…","expiresIn":900,"sessionStart":1782…,"avatarId":"…" } }
POST/v1/wardrobe/refresh-tokenpk_

Refresh before expiry. Enforced absolute session cap: 8 hours — after that, re-mint with sk_.

POST/v1/wardrobe/savepk_

Apply a look with a valid edit token and rebuild. Used by the Wardrobe; you can call it directly too. The request must come from a whitelisted origin.

BodyTypeNotes
tokenstringedit token — identifies the avatar (required)
recipeobject?omit to rebuild the avatar's saved look unchanged
genderstring?applied with the recipe
portraitstring?base64 webp thumbnail, stored against the new build (best-effort)
{ "data": { "avatarId":"…", "build": { "id":"…","url":"…","status":"completed" } } }

Fails with 403 assets_locked if the look contains a pooled item this avatar has not unlocked, and 422 not_decent if it is missing required clothing.

POST/v1/wardrobe/versionspk_

The token avatar's version history — the same payload as GET /v1/avatars/:id/versions, but readable from the browser with a publishable key + edit token instead of an sk_.

BodyNotes
tokenedit token (required)
POST/v1/wardrobe/revertpk_

Revert the token avatar to one of its versions from the browser. Same effect as the sk_ revert; no rebuild is downloaded.

BodyNotes
tokenedit token (required)
versionIdid from /v1/wardrobe/versions

Errors

Errors use HTTP status codes + { error: { code, message } }.

StatuscodeMeaning
401missing_key / invalid_keyNo/invalid x-api-key
403wrong_key_type / insufficient_scopee.g. used pk_ for a write
400invalidBad input (e.g. unknown asset id in recipe)
404not_foundAvatar not in your app
422build_failedMerge/optimise failed
401session_capEdit-token 8h cap reached — re-mint
401invalid_tokenEdit token missing, malformed or expired
403assets_lockedRecipe holds a pooled asset this avatar hasn't unlocked — info.locked lists them
403origin_not_allowedWardrobe call from an origin not on the app's whitelist
403token_app_mismatchEdit token was minted for a different app
404no_animations/v1/animations.glb with no enabled clip that has a GLB
422insufficient_assetsRandom: a required slot has no enabled, non-pooled asset
422not_decentWardrobe save: look is missing required clothing — info.missing lists the slots
422save_failedWardrobe save: update or rebuild failed