API endpoint reference
Every /api/v1 route: parameters, response fields, and example JSON for account, catalog, and download endpoints.
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:
{ "data": { "…": "…" } }A collection, always with pagination:
{
"data": [ { "…": "…" } ],
"pagination": { "limit": 50, "offset": 0, "total": 214, "has_more": true, "next_cursor": null }
}An error:
{
"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:
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit | integer | 50 | Clamped to 1–100. |
offset | integer | 0 | Clamped 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.
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))
doneGET /v1/me
Who you are, what you own, and which key you are holding. Scope: catalog:read.
No parameters.
{
"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"
}
}
}| Field | Notes |
|---|---|
entitlements.lifetime | true when you hold lifetime access. |
entitlements.owned_product_slugs | The pack (or lifetime) slugs your purchases cover. |
entitlements.pack_count | Published packs you can download right now. |
entitlements.asset_count | Published assets you can download, free ones included. |
key.preview | The 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.
{
"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 }
}| Field | Notes |
|---|---|
asset_count | Published members of the pack. |
categories | Categories its members fall into, sorted. |
purchased_at, order_id | The order that bought this pack, or null for a lifetime holder — they have no per-pack order and we do not invent one. |
downloads | Absolute 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.
| Parameter | Values | Default | Notes |
|---|---|---|---|
owned | true, false, all | true | true = what you can download now; false = only what you cannot; all = the whole published catalog with an entitled flag on each row. |
pack | pack slug | — | Members of one pack. |
category | category slug | — | One category. |
q | text | — | Name/slug search. |
limit, offset | integers | 50, 0 |
curl -s "$API/assets?owned=all&category=vehicles&limit=100" -H "$AUTH" \
| jq -r '.data[] | select(.entitled == false) | "\(.slug)\t\(.pack_slug)"'{
"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 }
}| Field | Notes |
|---|---|
tier | free or premium. |
pack_slug | The pack this asset is listed under, or null. |
entitled | Whether your purchases cover it. Free assets are always true. |
triangles, file_size_bytes | Integers, for the baked GLB. |
preview_url vs download_url in a script
They are different files and they behave differently:
download_urlis the licensed artifact behind your entitlement. This is what you want in a build pipeline.preview_urlis 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:
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.
{
"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 }
}| Field | Notes |
|---|---|
amount | Minor 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". |
currency | Lowercase ISO code. |
license_key_preview | A 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.
| Parameter | Values | Default |
|---|---|---|
edition | bundle, authoring | bundle |
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.
| Parameter | Values | Default |
|---|---|---|
edition | glb, authoring | glb |
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.
# 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/downloadWhat to expect:
- A single range (
bytes=0-99,bytes=100-,bytes=-100) returns206 Partial ContentwithContent-Range. - A multi-range request (
bytes=0-9,20-29) is ignored and answered with the whole file,200. - A malformed
Rangeis likewise ignored and answered with200. - A range that starts past the end of the file returns
416withContent-Range: bytes */<size>. If-Rangeis honoured against theETag. If the file changed under you, you get the full file instead of a corrupt splice — which is exactly whatIf-Rangeis for. A date-formIf-Rangeis 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.
{
"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.
{
"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.