docs / api / api-endpoints
API

API endpoint reference

Every /api/v1 route: parameters, response fields, and example JSON for account, catalog, and download endpoints.

DX
Developer Experience Team
Updated Aug 11, 2026 · 9 min read

Base URL: https://threejsassets.com/api/v1. Every route is a GET.

Authenticated routes need an API key (API authentication and keys); the /v1/catalog/* routes need nothing at all. Nothing here is cached — responses are per-account and carry Cache-Control: no-store.

The response envelope

Every route answers in one of three shapes, so you write one parser.

A single object:

single.json
{ "data": { "…": "…" } }

A collection, always with pagination:

collection.json
{
  "data": [ { "…": "…" } ],
  "pagination": { "limit": 50, "offset": 0, "total": 214, "has_more": true, "next_cursor": null }
}

An error:

error.json
{
  "error": {
    "code": "not_entitled",
    "message": "This isn't part of your purchases. Buy the pack or lifetime access to download it.",
    "request_id": "5f3c9a10-2d6b-4b2e-9f77-1c0a8de41b52"
  }
}

request_id is always present and always matches the x-request-id header on the same response. Quote it in a support email and we can find the exact call. See API errors and rate limits for the full code table.

Pagination

Every collection takes the same two parameters:

ParameterTypeDefaultNotes
limitinteger50Clamped to 1–100.
offsetinteger0Clamped to 0 or more.

An unparseable value falls back to the default rather than erroring. has_more is the flag to loop on. next_cursor is always null today; it exists so cursor paging can arrive later without a new API version.

page-through.sh
offset=0
while :; do
  page=$(curl -s "$API/assets?owned=all&limit=100&offset=$offset" -H "$AUTH")
  echo "$page" | jq -r '.data[].slug'
  [ "$(echo "$page" | jq -r '.pagination.has_more')" = "true" ] || break
  offset=$((offset + 100))
done

GET /v1/me

Who you are, what you own, and which key you are holding. Scope: catalog:read.

No parameters.

me.json
{
  "data": {
    "account": { "id": "usr_…", "email": "you@studio.com", "name": "You" },
    "entitlements": {
      "lifetime": false,
      "owned_product_slugs": ["cozy-village"],
      "pack_count": 1,
      "asset_count": 214
    },
    "key": {
      "id": "key_…",
      "name": "ci-build-server",
      "preview": "tja_live_9f2cKp",
      "scopes": ["catalog:read", "downloads:read"],
      "created_at": "2026-08-02T09:14:00.000Z",
      "last_used_at": "2026-08-02T09:16:22.000Z"
    }
  }
}
FieldNotes
entitlements.lifetimetrue when you hold lifetime access.
entitlements.owned_product_slugsThe pack (or lifetime) slugs your purchases cover.
entitlements.pack_countPublished packs you can download right now.
entitlements.asset_countPublished assets you can download, free ones included.
key.previewThe first characters of the key — enough to identify it, never enough to use it.

Counts describe what the API will actually serve, so they match what /v1/packs and /v1/assets return.


GET /v1/packs

The packs you own, newest release first. Scope: catalog:read.

Parameters: limit, offset.

packs.json
{
  "data": [
    {
      "slug": "cozy-village",
      "name": "Cozy Village",
      "description": "A stylised low-poly village kit.",
      "asset_count": 96,
      "categories": ["buildings", "nature", "props"],
      "purchased_at": "2026-06-11T18:02:41.000Z",
      "order_id": "ord_…",
      "downloads": {
        "bundle": "https://threejsassets.com/api/v1/packs/cozy-village/download",
        "authoring": "https://threejsassets.com/api/v1/packs/cozy-village/download?edition=authoring"
      }
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "has_more": false, "next_cursor": null }
}
FieldNotes
asset_countPublished members of the pack.
categoriesCategories its members fall into, sorted.
purchased_at, order_idThe order that bought this pack, or null for a lifetime holder — they have no per-pack order and we do not invent one.
downloadsAbsolute URLs. Use them instead of building your own.

Only published packs appear. A pack still in its coming-soon phase is invisible here even to a lifetime holder, because there is no file to download yet.


GET /v1/assets

Individual assets, filtered and paginated. Scope: catalog:read.

ParameterValuesDefaultNotes
ownedtrue, false, alltruetrue = what you can download now; false = only what you cannot; all = the whole published catalog with an entitled flag on each row.
packpack slugMembers of one pack.
categorycategory slugOne category.
qtextName/slug search.
limit, offsetintegers50, 0
what-should-i-buy.sh
curl -s "$API/assets?owned=all&category=vehicles&limit=100" -H "$AUTH" \
  | jq -r '.data[] | select(.entitled == false) | "\(.slug)\t\(.pack_slug)"'
assets.json
{
  "data": [
    {
      "slug": "bld-tavern-inn-01",
      "name": "Tavern Inn 01",
      "tier": "premium",
      "category": "buildings",
      "pack_slug": "cozy-village",
      "entitled": true,
      "triangles": 4820,
      "file_size_bytes": 184320,
      "updated_at": "2026-06-11T12:00:00.000Z",
      "preview_url": "https://threejsassets.com/models/bld-tavern-inn-01.glb",
      "download_url": "https://threejsassets.com/api/v1/assets/bld-tavern-inn-01/download"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 96, "has_more": true, "next_cursor": null }
}
FieldNotes
tierfree or premium.
pack_slugThe pack this asset is listed under, or null.
entitledWhether your purchases cover it. Free assets are always true.
triangles, file_size_bytesIntegers, for the baked GLB.

preview_url vs download_url in a script

They are different files and they behave differently:

  • download_url is the licensed artifact behind your entitlement. This is what you want in a build pipeline.
  • preview_url is the public baked preview GLB — the same file the website's viewer loads. It is fine for evaluation, but it is not the licensed deliverable and does not carry named hierarchies or the editable extras.

A bare curl of preview_url with no headers is treated as an anonymous direct hit and redirects to the sign-in page. Send your API key with it and it streams normally:

preview.sh
curl -L --fail -H "Authorization: Bearer $TJA_API_KEY" \
  -o preview.glb "https://threejsassets.com/models/bld-tavern-inn-01.glb"

GET /v1/orders

Your purchase history, newest first. Scope: catalog:read.

Parameters: limit, offset.

orders.json
{
  "data": [
    {
      "id": "ord_…",
      "product_slug": "cozy-village",
      "product_name": "Cozy Village",
      "status": "paid",
      "amount": 4900,
      "currency": "usd",
      "created_at": "2026-06-11T18:02:41.000Z",
      "license_key_preview": "TJA_A1B2-…-9Z8Y"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1, "has_more": false, "next_cursor": null }
}
FieldNotes
amountMinor units (cents) as an integer — never a float, never a formatted string. null when an older order never recorded a total; that means "unknown", not "free".
currencyLowercase ISO code.
license_key_previewA mask. The full key is never returned by the API — find it under TJA download credential in Account → Downloads.

GET /v1/packs/:slug/download

Streams the licensed pack ZIP. Scope: downloads:read.

ParameterValuesDefault
editionbundle, authoringbundle

bundle is the standard licensed pack. authoring is the editable edition, when the pack has one; if it does not, you get 404 file_unavailable with a message saying so.

Response headers: Content-Type: application/zip, Content-Length, Content-Disposition: attachment; filename="cozy-village-pack.zip", a strong ETag, Accept-Ranges: bytes, Cache-Control: no-store.

GET /v1/assets/:slug/download

Streams one asset file. Scope: downloads:read.

ParameterValuesDefault
editionglb, authoringglb

glb is the licensed model file (Content-Type: model/gltf-binary). authoring is the asset's editable package as a ZIP, where one exists.

Free assets stream to any valid key. Premium assets need the covering pack or lifetime access, otherwise 403 not_entitled.

Resuming and byte ranges

Both download endpoints advertise Accept-Ranges: bytes and mean it — useful when a 48 MB ZIP dies halfway over a flaky CI connection.

range.sh
# The first 1 MB
curl -s -H "Authorization: Bearer $TJA_API_KEY" \
     -H "Range: bytes=0-1048575" \
     -o part1 https://threejsassets.com/api/v1/packs/cozy-village/download

# Resume whatever curl already has on disk
curl -L --fail -C - -H "Authorization: Bearer $TJA_API_KEY" \
     -o cozy-village-pack.zip \
     https://threejsassets.com/api/v1/packs/cozy-village/download

What to expect:

  • A single range (bytes=0-99, bytes=100-, bytes=-100) returns 206 Partial Content with Content-Range.
  • A multi-range request (bytes=0-9,20-29) is ignored and answered with the whole file, 200.
  • A malformed Range is likewise ignored and answered with 200.
  • A range that starts past the end of the file returns 416 with Content-Range: bytes */<size>.
  • If-Range is honoured against the ETag. If the file changed under you, you get the full file instead of a corrupt splice — which is exactly what If-Range is for. A date-form If-Range is treated as a mismatch.

Downloads spend a tighter rate-limit budget than metadata reads, so fetch them one at a time.


The public catalog

/v1/catalog/* needs no key, no scope, and no account. It is the storefront as JSON: anything a public pack or asset page already shows, and nothing more. There is no entitled flag, no order data, and no file URL at all — the anonymous catalog does not even name the download endpoint. File access lives on the authenticated /v1/assets routes and requires a key.

Only published items appear.

GET /v1/catalog/packs

Parameters: q, category, limit, offset. Newest release first.

catalog-packs.json
{
  "data": [
    {
      "slug": "cozy-village",
      "name": "Cozy Village",
      "description": "A stylised low-poly village kit.",
      "price_cents": 4900,
      "currency": "usd",
      "categories": ["buildings", "nature", "props"],
      "asset_count": 96,
      "image_url": "https://threejsassets.com/…/cozy-village.png",
      "demo_url": "https://threejsassets.com/packs/cozy-village/demo",
      "released_at": "2026-05-02T10:00:00.000Z",
      "url": "https://threejsassets.com/packs/cozy-village"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 12, "has_more": false, "next_cursor": null }
}

price_cents is an integer in minor units. image_url is a poster or promo PNG and is null when the pack has neither; demo_url is the pack's public demo page, or null when it has no demo scene.

GET /v1/catalog/packs/:slug

The same pack object plus an assets array holding the full roster, each entry in the shape below. A pack that is not published — including a coming-soon one — is a 404 not_found here, exactly as it is on the website.

GET /v1/catalog/assets

Parameters: q, category, pack, limit, offset. Sorted by name.

catalog-assets.json
{
  "data": [
    {
      "slug": "bld-tavern-inn-01",
      "name": "Tavern Inn 01",
      "description": "A two-storey timbered inn.",
      "tier": "premium",
      "category": "buildings",
      "pack_slug": "cozy-village",
      "triangles": 4820,
      "file_size_bytes": 184320,
      "version": "1.2.0",
      "poster_url": "https://threejsassets.com/…/bld-tavern-inn-01.png",
      "url": "https://threejsassets.com/assets/bld-tavern-inn-01"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 480, "has_more": true, "next_cursor": null }
}

Images here are poster PNGs. The public catalog carries no file URL of any kind — no model preview and no download_url. Downloading or importing a file goes through the authenticated /v1/assets endpoints, which need an API key and an entitlement.

GET /v1/catalog/assets/:slug

One asset in the same shape, or 404 not_found.

Next steps

API endpoint reference — threejsassets.com