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:
| Approach | Use it when |
|---|---|
| API-only | Your app builds avatars programmatically (create recipe → build → load the GLB). You never need our UI. |
| Embedded Wardrobe | You 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:
| Key | Prefix | Where | Can do |
|---|---|---|---|
| secret | sk_ | Server only | Everything: create / update / build / delete, mint edit tokens |
| publishable | pk_ | Browser-safe | Read assets & avatars, refresh edit tokens, wardrobe save |
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).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
Returns the wearable slots for a gender with counts.
| Query | Type | Notes |
|---|---|---|
gender | string | male | female |
{ "data": [ { "slot": "Hat", "count": 29 }, { "slot": "Shirt", "count": 23 }, … ] }
List assets
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 pooled — true 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.
| Query | Notes |
|---|---|
gender | male | female |
category | e.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
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" }, … ] }
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
| Body | Type | Notes |
|---|---|---|
gender | string | male | female |
recipe | object | map of slot → assetId |
externalUserRef | string? | your user id (avatars are scoped per app + ref) |
name, metadata | string?, 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
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) | Type | Notes |
|---|---|---|
gender | string? | male | female (random if omitted) |
name, externalUserRef, metadata | string?, 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
The raw avatar record (recipe, status, current build id).
Get config
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
All avatars for your app, newest first.
| Query | Type | Notes |
|---|---|---|
externalUserRef | string? | filter to one of your users, e.g. player-42 |
search | string? | match on name / user ref |
limit | number? | default 50 |
offset | number? | default 0 |
Update avatar
Change the recipe, name or metadata. Re-build to produce a new GLB.
Delete avatar
Removes the avatar and its builds.
Version history
Each publish saves a revertable version (recipe + build + portrait).
{ "data": [ { "id":"…","version":3,"recipe":{…},"gender":"male","name":"…","buildId":"…","glbUrl":"…","portraitUrl":"…","createdAt":1720000000000,"isCurrent":true }, … ] }
Restore a previous version as the current one (its recipe, build and portrait).
Build (merge → GLB)
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 |
|---|---|
animations | array 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
Build history for the avatar (newest first).
Get current GLB
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)
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.
| Body | Notes |
|---|---|
b64 | base64-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.
glb/Adult/…/Hat.glb), premium/epic use premium/<uuid>.glb.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" ] } ] }
The asset ids currently unlocked for this avatar.
{ "data": [ "premium/<uuid>.glb" ] }
Unlock one or many pooled assets for this avatar so its user can wear them. Idempotent — re-unlocking is a no-op.
| Body | Notes |
|---|---|
assetIds | array of recipe ids — unlock many at once |
assetId | a 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" ] } }
Re-lock assets for this avatar. Same body shape (assetIds or assetId).
{ "data": { "avatarId":"…", "relocked":[ "premium/<uuid>.glb" ] } }
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.
{ "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.
How many enabled items the pool holds. Pair it with the avatar's unlocks to show “7 / 20 collected”.
{ "data": { "poolId": "…", "total": 20 } }
Unlocks one random un-owned item from the pool for this avatar.
| Body | Type | Notes |
|---|---|---|
poolId | string | required |
idempotencyKey | string? | 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:
- Server-side, mint a short-lived edit token for the avatar (
sk_). - Open the Wardrobe with your publishable key + the token.
- Listen for the
lowpolyavatar:savedmessage to get the new GLB URL. - Optional — append
&close=1to show a✕in the Wardrobe header. It postslowpolyavatar:closewhen clicked andlowpolyavatar:readyonce the header is visible, so your host can show a fallback close control untilreadyarrives, 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
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":"…" } }Refresh before expiry. Enforced absolute session cap: 8 hours — after that, re-mint with sk_.
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.
| Body | Type | Notes |
|---|---|---|
token | string | edit token — identifies the avatar (required) |
recipe | object? | omit to rebuild the avatar's saved look unchanged |
gender | string? | applied with the recipe |
portrait | string? | 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.
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_.
| Body | Notes |
|---|---|
token | edit token (required) |
Revert the token avatar to one of its versions from the browser. Same effect as the sk_ revert; no rebuild is downloaded.
| Body | Notes |
|---|---|
token | edit token (required) |
versionId | id from /v1/wardrobe/versions |
Errors
Errors use HTTP status codes + { error: { code, message } }.
| Status | code | Meaning |
|---|---|---|
| 401 | missing_key / invalid_key | No/invalid x-api-key |
| 403 | wrong_key_type / insufficient_scope | e.g. used pk_ for a write |
| 400 | invalid | Bad input (e.g. unknown asset id in recipe) |
| 404 | not_found | Avatar not in your app |
| 422 | build_failed | Merge/optimise failed |
| 401 | session_cap | Edit-token 8h cap reached — re-mint |
| 401 | invalid_token | Edit token missing, malformed or expired |
| 403 | assets_locked | Recipe holds a pooled asset this avatar hasn't unlocked — info.locked lists them |
| 403 | origin_not_allowed | Wardrobe call from an origin not on the app's whitelist |
| 403 | token_app_mismatch | Edit token was minted for a different app |
| 404 | no_animations | /v1/animations.glb with no enabled clip that has a GLB |
| 422 | insufficient_assets | Random: a required slot has no enabled, non-pooled asset |
| 422 | not_decent | Wardrobe save: look is missing required clothing — info.missing lists the slots |
| 422 | save_failed | Wardrobe save: update or rebuild failed |