API endpoint reference
Every /api/v1 route: parameters, response fields, and example JSON for account, catalog, and download endpoints.
On this page (25)
- The response envelope
- Pagination
- GET /v1/me
- GET /v1/products
- GET /v1/products/:slug
- GET /v1/products/:slug/download
- GET /v1/packs
- GET /v1/assets
- preview_url vs download_url in a script
- GET /v1/orders
- GET /v1/packs/:slug/download
- GET /v1/assets/:slug/download
- Resuming and byte ranges
- GET /v1/icons/:name
- GET /v1/icons/:name/download
- GET /v1/icons/library
- The public catalog
- GET /v1/catalog/packs
- GET /v1/catalog/packs/:slug
- GET /v1/catalog/assets
- GET /v1/catalog/assets/:slug
- GET /v1/catalog/products
- GET /v1/catalog/products/:slug
- GET /v1/catalog/icons
- Next steps
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,
"product_count": 2,
"products_by_type": { "pack": 1, "icons": 1 }
},
"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. |
entitlements.product_count | Everything /v1/products would list for you, of every kind. |
entitlements.products_by_type | product_count split by kind β {"pack": 1, "icons": 1}. Sparse: a kind you own nothing of is left out entirely rather than reported as 0, so never assume a key is there. |
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, /v1/assets and /v1/products return.
GET /v1/products#
Everything you own, of every kind β asset packs, character sets, the icons library β sorted by name. Scope: catalog:read.
| Parameter | Values | Default | Notes |
|---|---|---|---|
type | pack, character, icons, β¦ | β | One kind. Matched exactly: a typo matches nothing rather than quietly falling back to packs. |
limit, offset | integers | 50, 0 |
{
"data": [
{
"slug": "cozy-village",
"name": "Cozy Village",
"product_type": "pack",
"purchased_at": "2026-06-11T18:02:41.000Z",
"order_id": "ord_β¦",
"deliverables": [
{
"edition": "bundle",
"url": "https://threejsassets.com/api/v1/products/cozy-village/download?edition=bundle",
"size_bytes": 48210944
}
]
}
],
"pagination": { "limit": 50, "offset": 0, "total": 2, "has_more": false, "next_cursor": null }
}| Field | Notes |
|---|---|
product_type | What kind of thing it is. pack for an asset pack, character for a character set, icons for the icons library. |
purchased_at, order_id | The order that bought it, or null when lifetime access or a free tier covers it instead β that is not a missing date, and we do not invent one. |
deliverables | The download editions that exist right now, each with an absolute URL and a size in bytes. |
deliverables is empty when nothing is built yet β a product you own whose files are still being prepared. Treat that as "no button", not as an error: the array tells you what you can fetch, so branch on it instead of guessing a URL.
This endpoint overlaps /v1/packs on purpose. A pack you own appears in both, because /v1/packs and /v1/assets are in people's scripts and are not going anywhere; /v1/products is the one place that also knows about the kinds of things that are not packs.
GET /v1/products/:slug#
One owned product, in the same shape as a row above. Scope: catalog:read.
The refusal tells you which problem you have:
404 not_foundβ no such product, or one you cannot see: a coming-soon, draft or archived product, and a character set that is not public.403 not_entitledβ it exists and is on sale, you just have not bought it.
A product you bought that has since been archived still resolves here. Past purchases keep their downloads.
GET /v1/products/:slug/download#
Streams the licensed file for any product you own. Scope: downloads:read.
| Parameter | Values | Default | Notes |
|---|---|---|---|
edition | bundle, authoring | bundle | Only meaningful for pack-shaped products. |
A character set has one deliverable, its editable source bundle, and the icons library has one, the library ZIP; for those the edition parameter is simply not consulted. Use the url from deliverables and you never have to think about it.
Refusals match the detail endpoint exactly, so a download URL can never confirm something the detail URL hid: 404 not_found for unknown or invisible, 403 not_entitled for unbought, 404 file_unavailable when you own it but the file is not built yet.
Headers and Range behavior are the same as the pack download below.
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.
GET /v1/icons/:name#
One icon by name. Scope: downloads:read β not catalog:read, because the response can carry the artwork itself.
{
"data": {
"name": "arrow-up",
"category": "arrows",
"tags": ["arrow", "external", "link"],
"preview_url": "https://threejsassets.com/icons/preview/9f2cβ¦.png",
"url": "https://threejsassets.com/icons/arrows/arrow-up",
"new": false,
"body": "<path d=\"β¦\"/>"
}
}| Field | Notes |
|---|---|
preview_url | A public 256px PNG preview. Present for every icon, entitled or not. |
new | true when the icon arrived in the most recent release. |
body | The <path> markup. The key is absent when you are not entitled to that icon β its presence is how you tell. There is no entitled flag. |
A key with only catalog:read gets 403 insufficient_scope here. If all you want is metadata, use the public /v1/catalog/icons instead β no key, no scope, and no body at any entitlement level.
Unlike the file endpoints, this one spends the ordinary metadata rate-limit budget.
GET /v1/icons/:name/download#
One icon as a file. Scope: downloads:read.
| Parameter | Values | Default |
|---|---|---|
format | svg, png | svg |
size | 16, 24, 32, 48, 64, 96, 128, 256, 512 | 96 |
color | a six-digit hex color, #RRGGBB | #0B1F16 |
size and color apply to both formats β the SVG comes out at the size and color you asked for, ready to paste. An unlisted size or a malformed color falls back to the default rather than erroring, and size is an allow-list rather than a free number, so ?size=9000 gets you 96px.
curl -L --fail -H "Authorization: Bearer $TJA_API_KEY" \
-o arrow-up.png \
"https://threejsassets.com/api/v1/icons/arrow-up/download?format=png&size=128"An icon outside your collections is 403 not_entitled; an icon that does not exist is 404 not_found.
GET /v1/icons/library#
Streams the whole icon library as one ZIP. Scope: downloads:read. No parameters.
The gate here is coverage, not ownership: you get the ZIP only when your entitlements cover every icon in it, because the file is cut from the complete library. That has one consequence worth knowing before it surprises you β if a premium set lands that your purchase does not include, this endpoint starts answering 403 not_entitled while every individual icon you do own keeps downloading through /v1/icons/:name/download exactly as before. Loop the icons you own rather than treating the ZIP as the only path.
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.
GET /v1/catalog/products#
The storefront as JSON, past packs: every published product of every kind, sorted by name.
Parameters: q, type, limit, offset. type is matched exactly β an unrecognized value matches nothing rather than falling back to packs.
{
"data": [
{
"slug": "cozy-village",
"type": "pack",
"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": 14, "has_more": false, "next_cursor": null }
}A product with no asset roster β a character set, the icons library β reports categories: [], asset_count: 0, and image_url: null; url is its page on the site. Like the rest of the public catalog it carries no file URL of any kind.
GET /v1/catalog/products/:slug#
The same product object plus an assets array: the full roster for a pack-backed product, and an empty array for one that has no roster. A product that is not published β coming-soon included β is 404 not_found here, exactly as it is on the website.
GET /v1/catalog/icons#
Icon metadata, no key required. Parameters: q, category, limit, offset. q matches the name or any tag.
Rows are the same shape as /v1/icons/:name returns, minus one thing: there is never a body, at any entitlement level. There is no account on an anonymous request to establish one, so what travels is the public 256px preview_url an anonymous visitor already sees on the site. Fetching artwork means the keyed /v1/icons/* routes.
Next steps#
Ask us directly β we answer support mail ourselves.