Asset library API for Unity clients
Building scenes rather than fetching files? See the Playground API, the Unity client (a ready-made editor package) and MCP.
Machine-readable spec:
GET /api/v1/openapi.yaml(or.json) — OpenAPI 3.1, covering every/api/v1endpoint (a test keeps it in step with the routes). Generate a typed client from it, or read on for the essentials.
Everything a Unity editor tool or runtime browser needs: search with
type-ahead, browse, fetch metadata, download files. Same results as the
web library — both go through AssetSearch / AssetSuggest.
Auth
Create a key at Account → API keys (/account?tab=api_keys). Send it on
every request:
Authorization: Bearer <key>
Search language
The q parameter (both endpoints below) is loose: every word must appear
in the name, path, description or a tag — any order, any case. Mix in:
| Token | Meaning |
|---|---|
#prop or tag:prop |
must have this tag |
kind:model |
model, material, texture, image, animation |
rig:synty |
rig family: synty, mixamo, rigify (Blender), unreal (UE4 mannequin names), other |
humanoid, rigged, animation |
shorthand tags |
@synty/PolygonOffice |
only under this folder (scope, pack, folder) |
"Chair_Office" |
quoted phrase |
Separate params do the same and combine with the text: tags[]=prop,
kind=model, under=@synty/PolygonOffice.
Type-ahead
GET /api/v1/search/suggest?q=office ch&tags[]=prop
The last word is treated as the one being typed:
{
"query": "office ch",
"tags": [{ "slug": "character", "name": "character", "count": 403 }],
"kinds": [],
"folders": [{ "path": "@synty/PolygonOffice", "count": 838 }],
"assets": [{ "id": "gx287wuz", "name": "SM_Prop_Chair_Office_01", "kind": "model",
"path": "@synty/PolygonOffice/Models/SM_Prop_Chair_Office_01.fbx",
"preview_url": "https://cdn.arx.dev/…png",
"thumbnail_url": "https://cdn.arx.dev/…png",
"url": "https://arx.dev/library/@synty/PolygonOffice/Models/SM_Prop_Chair_Office_01.fbx" }],
"total": 12
}
thumbnail_urlis always set for models and animations: a rendered preview, or a stock image for clips (which have no mesh).- Suggest has its own rate limit (3 600 requests/hour per key), separate from the general 600/hour API limit. Still debounce ~200 ms and cancel in-flight requests when the user types again.
Filters, facets and sorting
Every filter the web library has works on /api/v1/assets (and the
suggest endpoint), combined with q:
| Param | Example | Meaning |
|---|---|---|
kind |
model |
asset kind |
pack[] |
pack[]=PolygonOffice |
pack (repeatable) |
category[] |
prop |
Synty category tag |
object[] |
chair |
object tag (derived from names) |
theme[] |
forest |
theme tag (setting / biome people tag by hand) |
rig[] |
synty |
rig family |
set[] |
set[]=k2v9x0aa |
an animation set’s clips (see below) |
compatible_with |
an asset id | a character → animations it can play; an animation → characters it plays on |
flag[] |
humanoid, animated, variants, multi_mesh |
feature flags |
tris_min / tris_max |
tris_max=1000 |
triangle range |
size_min / size_max |
bytes | file size range |
tris / size |
tris=lt1k |
the facet buckets (see facets for keys) |
under |
@synty/PolygonOffice |
folder |
sort |
name, name_desc, newest, oldest, size, size_desc, tris, tris_desc, path, relevance |
order |
facets=1 |
add facets with live counts for each group |
|
collapse_twins=1 |
list a model + prefab twin once, as the prefab (see below) |
When a search finds nothing, the response includes did_you_mean (the
closest real word) — show it as a suggestion. Each asset has a
display_name that adds the pack when the bare name isn’t unique
(“Characters — PolygonOffice”); prefer it in lists.
Model + prefab twins
Synty packs ship most items twice: Models/SM_X.fbx and a prefab
Prefabs/…/SM_X.prefab that wraps just that model. The web library shows
the pair as one card (the prefab); the API lists every file unless you add
collapse_twins=1 (works on /assets, /search/suggest and /folders).
A model is only dropped when its prefab is in the same results, so
kind=model still lists every model. Each asset’s twin_id is the other
side’s id — use it for a “Prefab | Model” switch.
Unity-ready packages (model + materials + textures)
A model on its own isn’t usable in Unity — its materials and textures (and their GUIDs) are separate assets. Two ways to get the whole set:
GET /api/v1/assets/:id/dependencies JSON: every file it needs, root first
GET /api/v1/assets/:id/package the same files + their .meta as a zip
Add ?variants=1 to include the colour-variant materials. Prefabs work the
same way and bring their meshes along.
Each dependency has role (root, mesh, material, texture),
unity_path (where it goes, e.g. Assets/Vendor/PolygonOffice/Models/…),
unity_guid, sha256, byte_size, url (CDN) and has_meta. A Unity
client can download only the files whose sha256 it doesn’t already have,
writing each to unity_path; the zip is the one-shot alternative — unzip it
into the project root and Unity relinks everything from the .meta files.
dependencies sends an ETag, so re-checking with If-None-Match is cheap.
In the web library the same zip is the Unity package button on an asset page.
Animation sets (what plays on what)
Clips are grouped by folder into animation sets (Opsive’s
…/Animations/Items/Melee/Sword is one), each with the rig it was made
for:
GET /api/v1/animation_sets every set
GET /api/v1/animation_sets?compatible_with=<id> the sets a character can play
GET /api/v1/animation_sets/:id?page=1&per=100 one set + its clips
GET /api/v1/assets?compatible_with=<character id>&kind=animation
GET /api/v1/assets?compatible_with=<animation id> characters it plays on
Humanoid clips (humanoid: true) play on any humanoid character —
Unity’s Humanoid avatar retargets them, and the web viewer does the same
through a canonical bone table. Non-humanoid clips only play on the same
skeleton: the same rig family, and for rig: other the same pack.
A set’s humanoid is true when most of its clips are; humanoid_count
says exactly how many.
Favourites and collections
GET /api/v1/favourites your favourites (assets)
POST /api/v1/favourites/:asset_id add
DELETE /api/v1/favourites/:asset_id remove
GET /api/v1/collections your collections
GET /api/v1/collections/:id items in order (asset JSON with url + thumbnail_url)
POST /api/v1/collections { name, description }
POST /api/v1/collections/:id/items { asset_id }
DELETE /api/v1/collections/:id/items/:asset_id
PATCH /api/v1/collections/:id/reorder { ids: [...] }
A typical Unity flow: build a collection for a scene in the web library,
then pull it here and download each url.
Bundles (whole collections for Unity)
A collection is private (default), unlisted (anyone signed in with the
link) or public (listed on Library → Bundles). Set it with
visibility on POST/PATCH /api/v1/collections. Any bundle you can see
downloads in one go, every item with its materials, textures and prefab meshes,
de-duplicated:
GET /api/v1/collections/:id/dependencies JSON: every file, ETag-cacheable
GET /api/v1/collections/:id/package the same files + .meta as one zip
GET /api/v1/collections/:id/dependencies?token=… someone else's unlisted bundle
dependencies has the same files shape as an asset’s (role is root for
the bundle’s own items), plus file_count, total_bytes and
too_big_for_zip. The zip is capped at 2 GB / 5,000 files (413 above
that), so for big bundles download per file from dependencies and skip
any sha256 you already have. Hidden assets are left out.
Flagging and tagging
Report a problem with an asset, or tag it with what it’s for. The same rules apply as for the Flag button and the tag editor on the web asset page:
POST /api/v1/assets/:id/flags { reason, note } report it
GET /api/v1/flags flags you filed (?asset_id=, ?state=open|resolved|dismissed)
POST /api/v1/assets/:id/tags { labels: ["forest", "beach"] }
DELETE /api/v1/assets/:id/tags/:slug remove a tag you added (admin keys: any tag)
reasonis one oftextures_wrong,materials_wrong,broken_model,wrong_scale,wrong_tags,duplicate,inappropriate,other.noteis up to 1,000 characters. You can have one open flag per reason on each asset; repeating it gets422.- An existing tag (or one of its aliases) is reused. A new label becomes a
theme tag, which shows up as the Theme filter (
theme[]=forest). Members get 200 tag actions a day: tagging one label on one asset is one action. Past the limit you get429. - Admins can hide an asset from the flag queue. A hidden asset drops out of
every listing and returns
404to non-admin keys.
Search results (paged)
GET /api/v1/assets?q=office chair&kind=model&page=1&per=50
GET /api/v1/assets?path=@synty/PolygonOffice/Models/SM_Prop_Chair_Office_01.fbx exact path
GET /api/v1/assets/:id full record + materials, variants, used_by
meta.current_page / total_pages / total_count drive paging. Download the
file from url (CDN, no auth needed for the file itself).
Cursor paging (long scrolls)
For a stable sort — newest, oldest, name, name_desc, path — ask for
cursor paging instead of page numbers. Pages stay consistent while assets are
being added, and deep pages don’t get slower:
GET /api/v1/assets?sort=name&per=100&paging=cursor first page
GET /api/v1/assets?sort=name&per=100&cursor=<next_cursor> following pages
meta is { per_page, total_count, next_cursor }; next_cursor is null on
the last page. Other sorts (relevance, size, triangles) answer 400 to cursor
paging — use page= for those.
Folders
Browse paths like a file tree, the same as the web folder pages:
GET /api/v1/folders → the @scopes
GET /api/v1/folders?path=@synty/PolygonOffice → subfolders + assets directly in it
GET /api/v1/folders?path=@synty/PolygonOffice/Models&sort=name&page=2&per=100
{ "path": "@synty/PolygonOffice",
"folders": [{ "name": "Models", "path": "@synty/PolygonOffice/Models", "count": 812 }],
"assets": [],
"meta": { "current_page": 1, "total_pages": 0, "total_count": 0, "per_page": 50, "total_under": 838 } }
Packs
A pack is a vendor kit — @synty/PolygonOffice for Synty packs, the scope
itself for other vendors (@opsive). Same grouping as the pack[] filter.
GET /api/v1/packs every pack: counts per kind, size, cover_urls
GET /api/v1/packs/@synty/PolygonOffice one pack (single-scope packs: /api/v1/packs/@opsive)
GET /api/v1/assets?pack[]=@synty/PolygonOffice&kind=prefab its assets, paged as usual
GET /api/v1/packs/@synty/PolygonOffice/package the whole pack as a Unity zip
The pack zip holds every file at its original Assets/… path with its
.meta. It is streamed as it is built (no Content-Length) and can run to
hundreds of MB — write it to disk as it arrives (DownloadHandlerFile), not
into memory. In the web library it is the Unity package button on a pack page.
Caching (ETags)
GET /api/v1/assets, /assets/:id, /search/suggest and /folders send an
ETag. Keep it with the cached response and send it back as If-None-Match:
an unchanged answer comes back as an empty 304 — no body to download or
parse. Responses are Cache-Control: private, max-age=30 (never cache them in
a shared proxy: they’re per user).
Usage and limits
Account → API keys shows each key’s requests today and over 7 days, with
type-ahead suggestions counted separately. Limits per key: 600 requests/hour
for the API, 3,600/hour for /api/v1/search/suggest. Over the limit you get
429 with Retry-After (seconds) — wait that long, then retry.
C# (UnityWebRequest) type-ahead
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class HolodeckSearch : MonoBehaviour
{
const string Base = "https://arx.dev";
[SerializeField] string apiKey; // from Account → API keys
Coroutine pending;
UnityWebRequest inFlight;
// Call on every keystroke; only the last call within 200 ms goes out.
public void OnQueryChanged(string text)
{
if (pending != null) StopCoroutine(pending);
pending = StartCoroutine(Suggest(text));
}
IEnumerator Suggest(string text)
{
yield return new WaitForSecondsRealtime(0.2f); // debounce
inFlight?.Abort(); // drop a stale request
var url = $"{Base}/api/v1/search/suggest?q={UnityWebRequest.EscapeURL(text)}";
using var req = UnityWebRequest.Get(url);
inFlight = req;
req.SetRequestHeader("Authorization", "Bearer " + apiKey);
req.SetRequestHeader("Accept", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) yield break; // 429 → back off (Retry-After header)
var data = JsonUtility.FromJson<SuggestResponse>(req.downloadHandler.text);
// render data.tags / data.folders / data.assets …
}
// Walk a whole folder or search with cursor paging, reusing cached
// pages: send the stored ETag; 304 means "your copy is still right".
readonly System.Collections.Generic.Dictionary<string, (string etag, string body)> cache = new();
public IEnumerator ListAll(string query, System.Action<Asset[]> onPage)
{
string cursor = null;
while (true)
{
var url = $"{Base}/api/v1/assets?q={UnityWebRequest.EscapeURL(query)}&sort=name&per=100"
+ (cursor == null ? "&paging=cursor" : $"&cursor={cursor}");
using var req = UnityWebRequest.Get(url);
req.SetRequestHeader("Authorization", "Bearer " + apiKey);
if (cache.TryGetValue(url, out var hit)) req.SetRequestHeader("If-None-Match", hit.etag);
yield return req.SendWebRequest();
string body;
if (req.responseCode == 304) body = cache[url].body;
else if (req.result == UnityWebRequest.Result.Success)
{
body = req.downloadHandler.text;
var etag = req.GetResponseHeader("ETag");
if (!string.IsNullOrEmpty(etag)) cache[url] = (etag, body);
}
else if (req.responseCode == 429)
{
int.TryParse(req.GetResponseHeader("Retry-After"), out var wait);
yield return new WaitForSecondsRealtime(Mathf.Max(wait, 5));
continue; // same cursor again
}
else yield break;
var page = JsonUtility.FromJson<AssetPage>(body);
onPage(page.assets);
cursor = page.meta.next_cursor;
if (string.IsNullOrEmpty(cursor)) yield break; // last page
}
}
[System.Serializable] public class AssetPage { public Asset[] assets; public CursorMeta meta; }
[System.Serializable] public class CursorMeta { public int per_page, total_count; public string next_cursor; }
[System.Serializable] public class SuggestResponse { public Tag[] tags; public Folder[] folders; public Asset[] assets; public int total; }
[System.Serializable] public class Tag { public string slug; public int count; }
[System.Serializable] public class Folder { public string path; public int count; }
[System.Serializable] public class Asset { public string id, name, display_name, kind, path, thumbnail_url, url; }
}