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/v1 endpoint (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_url is 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)
  • reason is one of textures_wrong, materials_wrong, broken_model, wrong_scale, wrong_tags, duplicate, inappropriate, other. note is up to 1,000 characters. You can have one open flag per reason on each asset; repeating it gets 422.
  • 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 get 429.
  • Admins can hide an asset from the flag queue. A hidden asset drops out of every listing and returns 404 to 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; }
}