CPL3D API

v1.0.0

Read-only access to the CPL3D design catalogue: model metadata, print profiles, filament requirements and per-account entitlement. Built for filament planners, library managers and anything else that wants the catalogue without scraping it.

Released 2026-09-04 · See changelog

Overview

Everything here is a plain JSON API over HTTPS. There is no SDK to install and no scraping involved — nothing about the site's HTML matters to any of these endpoints.

  • Model metadata — title, slug, description, dimensions, thumbnail
  • Print profiles per model — file size, plate count, estimated print time
  • Filament requirements — colour name, hex and weight in grams
  • Pokemon attributes — Pokedex number, generation, region, tags
  • Entitlement — the owned field tells you whether the key's owner can download each model

Downloads are opt-in per key

A key can read metadata and, if you gave it the downloads scope, fetch the actual .3mf files you own. Keys are metadata-only unless you ask for that scope when generating one, so no key issued before v1.1.0 can pull files. See the download endpoint.

Authentication

Every request except /version carries your key as a bearer token:

http
Authorization: Bearer pk_your_key_here

Keys look like pk_ followed by 32 hex characters. They are long-lived and revocable, stored only as a SHA-256 hash on our side — which means we cannot show you a key again after you generate it. Copy it once, store it somewhere safe, and rotate it if you lose it.

Getting a key

Log in, then go to Dashboard → Tools → Models API. Generating a key requires an active subscription.

Never ship a key to a browser

A key carries your account's quota and entitlement. Anything you put in client-side JavaScript is readable by anyone who opens DevTools. Keep it in a server environment variable, or in a desktop app's own credential store.

Base URL & versioning

text
https://cpl3d.com/api/v1

The version is in the path. Within a major version we only ever add fields — we will not remove or rename a documented field, because integrations poll these endpoints unattended and a silent rename breaks them with no error anyone sees. Treat unknown fields as forward compatibility, not as a bug.

Check GET /version to confirm compatibility before you ask a user for a key — it needs no authentication for exactly that reason.

Access, tiers & limits

TierRequestsWindow
Member (no subscription)100Rolling 30 days
SubscriberUnlimited

Each account may hold 3 active keys at a time. Members without a subscription may additionally create at most 3 keys per rolling 30 days. Rotating a key does not count against that creation limit.

Usage headers

Every successful response reports your current usage, so you can back off before you hit a limit rather than discovering it through a 429:

http
X-Api-Calls-Used: 12
X-Api-Calls-Limit: 100
X-Api-Period-Resets: 2026-10-04T00:00:00.000Z

X-Api-Calls-Limit reads Unlimited for subscribers.

Scopes

A key carries one or more scopes, chosen when you create it. A request to an endpoint your key lacks the scope for returns 403.

  • models — included on every key. Catalogue metadata and /me.
  • poke-balls — opt-in. Pokemon attributes and their filters.
  • downloads — opt-in. Request download links for models you own.

Download limits

Separate from the request limits above: 100 distinct models per rolling 24 hours per account. Re-downloading a model you already pulled inside that window does not count, so a retry, a resume, or re-fetching a file you already have is free. The cap is per account, not per key, so two keys share one budget. GET /me reports what is left.

Endpoints

GET/version

Returns the current API version. No authentication required, and it does not count against any limit.

Example

bash
curl "https://cpl3d.com/api/v1/version"

Response

json
{
  "version": "1.0.0",
  "released": "2026-09-04"
}

GET/categories

The categories present across the approved catalogue, each with a display label. No authentication required — this is catalogue vocabulary, not account data, so a client can build its filter row before anyone has pasted a key.

Response

json
{
  "categories": [
    { "id": "poke-ball", "label": "Pokéballs" }
  ]
}

Pass an id as category on /library, or as type on /models. The list is derived from the catalogue rather than hardcoded, so a new product line appears here on its own. An unknown category returns an empty page rather than an error.

One category today

Everything is currently poke-ball. Read the list rather than assuming it — that is the whole reason this endpoint exists.

GET/me

Confirms the presented key works and reports who it belongs to, what it may do, and live usage against both limits. Requires only the models scope, so a metadata-only key can still verify itself — it reports downloads_enabled: false rather than refusing.

Built for connection screens: show connected as X, 87 downloads left today instead of letting a user find the limit by hitting it. Never cached.

Example

bash
curl "https://cpl3d.com/api/v1/me" \
  -H "Authorization: Bearer pk_your_key_here"

Response

json
{
  "display_name": "Chris",
  "tier": "subscriber",
  "key": {
    "name": "LayerMate",
    "prefix": "pk_1a2b3c4...",
    "created_at": "2026-09-01T10:00:00.000Z",
    "last_used_at": "2026-09-04T17:58:11.000Z",
    "scopes": ["models", "downloads"],
    "downloads_enabled": true
  },
  "usage": {
    "requests_this_period": 342,
    "request_limit": null,
    "period_resets_at": "2026-10-01T10:00:00.000Z",
    "downloads_today": 13,
    "daily_download_limit": 100,
    "downloads_remaining_today": 87
  }
}

request_limit is null when the account has no request cap. The three download fields are null when the key lacks the downloads scope.

GET/library

Everything your account can download — purchases, subscription grants, gifts and qualifying early access. Never the whole catalogue.

This is the endpoint a sync client wants

/library is open to any member who owns at least one model. /models browses the entire catalogue and needs a subscription. Getting the files you already bought is not the same request as browsing the shop, so they are not the same endpoint.

Query parameters

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
limitnumber50Items per page. Clamped to a maximum of 100.
updated_sincestringISO 8601 timestamp. Returns only models whose files_updated_at is later. An unparseable value is ignored rather than rejected.
categorystringAn id from /categories. type is accepted as an alias.

Example

bash
curl "https://cpl3d.com/api/v1/library?limit=50" \
  -H "Authorization: Bearer pk_your_key_here"

Response

json
{
  "models": [
    {
      "id": 412,
      "name": "0143 Snorlax",
      "slug": "0143-snorlax",
      "thumbnail": "https://...supabase.co/.../0143-snorlax-thumbnail.webp",
      "files_updated_at": "2026-08-20T11:02:31.000Z",
      "owned": true,
      "downloadable": true,
      "estimated_print_time_seconds": 15200,
      "total_weight_grams": 53.5,
      "print_profiles": [
        {
          "id": 1841,
          "name": "0143 Snorlax AMS.3mf",
          "variant": "AMS",
          "isCommunity": false,
          "numberOfPlates": 2,
          "estimatedPrintTime": 13500,
          "totalWeightGrams": 53.5,
          "filamentColors": [
            { "hex": "#1F3A5F", "name": "Navy Blue", "weight": 42.5 },
            { "hex": "#F2F2F2", "name": "Jade White", "weight": 11 }
          ]
        }
      ]
    }
  ],
  "total": 145,
  "page": 1,
  "limit": 50,
  "total_pages": 3,
  "has_next": true
}

Ordered by files_updated_at descending, so the most recently changed models come first. Admins and mods get their own real grants here, not the full catalogue — their blanket access still applies to individual downloads.

GET/models

A paginated list of approved models, each with its print profiles and an owned flag. Requires the models scope.

Query parameters

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
limitnumber20Items per page. Clamped to a maximum of 100.
searchstringSubstring match on name and slug. Ignored below 2 characters.
sortstringnamename or created_at.
sortDirstringascasc or desc.
typestringFilter by model type. Currently only poke-ball.
updated_sincestringISO 8601 timestamp. Returns only models whose files_updated_at is later than this. An unparseable value is ignored rather than rejected.

Example

bash
curl "https://cpl3d.com/api/v1/models?limit=50&sort=created_at&sortDir=desc" \
  -H "Authorization: Bearer pk_your_key_here"

Response

json
{
  "models": [
    {
      "id": 412,
      "name": "0143 Snorlax",
      "slug": "0143-snorlax",
      "description": "Print-tested Snorlax ball. AMS and SPLIT profiles included.",
      "type": "poke-ball",
      "thumbnail": "https://...supabase.co/.../0143-snorlax-thumbnail.webp",
      "width_mm": 74.5,
      "depth_mm": 74.5,
      "height_mm": 78.2,
      "created_at": "2026-08-14T03:21:55.000Z",
      "files_updated_at": "2026-08-20T11:02:31.000Z",
      "owned": true,
      "print_profiles": [
        {
          "id": 1841,
          "name": "0143 Snorlax AMS.3mf",
          "variant": "AMS",
          "isCommunity": false,
          "fileSize": 18234491,
          "numberOfPlates": 2,
          "estimatedPrintTime": 13500,
          "filamentColors": [
            { "hex": "#1F3A5F", "name": "Navy Blue", "weight": 42.5 },
            { "hex": "#F2F2F2", "name": "Jade White", "weight": 11.0 }
          ]
        }
      ]
    }
  ],
  "total": 214,
  "page": 1,
  "limit": 50
}

Only approved models are returned

Models pending approval or rejected never appear here, regardless of who owns the key.

GET/models/{id}

Full detail for one model: every approved profile with its id, variant, plate count, print time and per-filament breakdown. Open to any member who owns at least one model — detail for a single model is not the bulk-catalogue feature that /models is.

Example

bash
curl "https://cpl3d.com/api/v1/models/412" \
  -H "Authorization: Bearer pk_your_key_here"

Returns the same model object shape as /library, unwrapped. A model that does not exist and one that is not currently approved both return the same 404 body, so an id sweep cannot be used to discover models the listing endpoints hide.

GET/poke-balls

The same catalogue with Pokemon attributes attached — Pokedex number, generation, region and tags — and filters for them. Requires the poke-balls scope.

Query parameters

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
limitnumber20Items per page. Clamped to a maximum of 100.
tagsstringComma-separated tag list. Matches models carrying all of them.
generationstringFilter by Pokemon generation.
regionstringFilter by region.
hasExtraBlackbooleanModels needing black filament beyond the core.
hasExtraWhitebooleanModels needing white filament beyond the button.
sortstringnamename or pokedex_num.

Example

bash
curl "https://cpl3d.com/api/v1/poke-balls?generation=1&sort=pokedex_num" \
  -H "Authorization: Bearer pk_your_key_here"

GET/models/{id}/download

Requests a one-time download link for one print profile. Returns JSON, not bytes. Requires the downloads scope and at least one owned model on the account.

Query parameters

ParameterTypeDefaultDescription
profilenumber | stringfirstThe profile id, or its exact name (case-insensitive), from a model's print_profiles. Omit for the first profile in display order, which is the AMS variant where one exists.

Response

json
{
  "download_url": "https://cpl3d.com/api/v1/download/8Kx2...e2",
  "expires_in": 300,
  "expires_at": "2026-09-04T18:05:00.000Z",
  "one_time_use": true,
  "profile": { "id": 1841, "name": "0143 Snorlax AMS.3mf" }
}

Example

bash
# 1. Ask for a link (returns JSON, not bytes)
curl "https://cpl3d.com/api/v1/models/412/download?profile=AMS" \
  -H "Authorization: Bearer pk_your_key_here"

# 2. Fetch it. No auth header. Works once, for 5 minutes.
curl -L -o snorlax-ams.3mf \
  "https://cpl3d.com/api/v1/download/8Kx2...e2"

Treat the link as a credential

The link needs no authentication, which is what lets any client follow it. So it is bearer-equivalent while it lives: anyone holding that URL can fetch the file once, within five minutes. Do not log it, put it in a URL bar, or pass it through a third party.

Errors

  • 403 — not entitled to this model, the key lacks the downloads scope, or the account owns nothing
  • 404 — model or profile not found, or the model has no approved profiles
  • 429 — the 100-distinct-models-per-24h cap
  • 503 — API downloads temporarily disabled

GET/download/{token}

Serves the watermarked .3mf. You do not construct this URL — use the download_url from the endpoint above verbatim. No Authorization header; follow redirects and read the filename from Content-Disposition.

The token names exactly one file and is spent the moment it is used, before any bytes are sent. Entitlement is checked again here, not just when the link was minted, so a refund or a cancelled subscription in the intervening minutes still denies the download. Query parameters are ignored entirely: nothing you append can change which file comes back.

Errors

  • 410 — already used, expired, or never valid. Mint a new link; do not retry this one.
  • 403 — entitlement changed since the link was created

Every download is watermarked

Files served here carry the same per-account watermark as website downloads, and each one is logged. That is how a leaked file is traced back to the account it came from.

Field reference

Model object

FieldTypeDescription
idnumberStable numeric identifier. Use this in later calls.
namestringDisplay name.
slugstringURL-friendly identifier.
descriptionstring | nullLong-form description.
typestring | nullModel type, e.g. poke-ball.
thumbnailstring | nullPublicly readable cover image URL (webp).
width_mmnumber | nullBounding width in millimetres.
depth_mmnumber | nullBounding depth in millimetres.
height_mmnumber | nullBounding height in millimetres.
created_atstringISO 8601 timestamp of first publish.
files_updated_atstringWhen this model's print profile files last changed. Does not move for metadata-only edits. Pair it with updated_since to poll for changes — see Syncing a library.
ownedbooleanWhether the key's owner may download this model — a purchase, a subscription grant, or a free model. Reflects download access only, not commercial licensing.
print_profilesarrayEvery profile for this model, in display order.

Print Profile object

FieldTypeDescription
namestringFilename including extension. The variant is encoded here — AMS, SPLIT, MC or U1 as a standalone token.
idnumberPass this as profile to the download endpoint. Stable, unlike the name.
variantstring | nullAMS, SPLIT, MC, U1, or null. Derived from the name, matched on a whole token, so you do not have to parse filenames.
isCommunitybooleanA member-uploaded profile we reviewed and approved but did not produce or test ourselves.
fileSizenumber | nullSize in bytes. Null if not yet recorded.
numberOfPlatesnumberBuild plates required.
estimatedPrintTimenumberEstimated print time in seconds.
filamentColorsarrayFilament breakdown for this profile.

Filament Color object

FieldTypeDescription
hexstringHex colour, e.g. #1F3A5F. Draw a swatch without loading an image.
namestringColour name as labelled in the profile.
weightnumberGrams of this colour required.

A model's total weight is not returned as its own field — sum weight across a profile's filamentColors.

Errors

Every error response on every endpoint is JSON with a single error string, safe to show a user. Never an HTML error page.

json
{ "error": "Invalid or revoked API key." }
StatusMeaning
400Invalid parameters.
401Missing, invalid or revoked API key.
403No active subscription, missing scope, or account suspended.
429Rate limit exceeded. Includes a Retry-After header.
500Server error. Safe to retry once.

A 429 carries Retry-After in seconds. Respect it and stop the in-flight batch rather than continuing to retry.

Syncing a library

If you are building something that keeps a local copy of a member's files up to date, this is the loop to write.

  1. Call GET /me once to confirm the key works and read the download allowance. Show the member which account they connected.
  2. Page GET /models to build the initial list. Keep the largest files_updated_at you saw.
  3. Download what the member wants: one GET /models/{id}/download per profile, then follow the returned link. Process serially, and stop the batch on a 429 rather than continuing.
  4. To poll later, call GET /models?updated_since=<that timestamp>. An empty models array means nothing changed. Do not re-list the catalogue.

On the update signal

files_updated_at moves only when a model's actual files change — a profile added, replaced, renamed or removed. Editing a description, a filament colour or a thumbnail does not move it, and a member-submitted profile does not move it until it has been reviewed and is actually downloadable. So if it changed, there is genuinely something new to fetch.

Backfill caveat, stated plainly: models published before this field existed carry their first-publish date. We did not invent a history we do not have, so a model last re-uploaded in 2025 reports 2025's publish date rather than the re-upload. Every change from v1.2.0 onward is exact.

Poll politely

Hourly is plenty; the catalogue changes a few times a month. A tight poll loop burns a non-subscriber's 100-request allowance in under two hours and tells you nothing new.

Changelog

v1.0.02026-09-04
  • First public release. GET /v1/models and GET /v1/poke-balls were already live; this is the first version to document them, pin a version to them, and commit to not breaking them without a major bump.
  • GET /v1/version - unauthenticated, so a client can check compatibility before it has a key.
  • GET /v1/me - key introspection. Confirms a pasted key works and reports live request and download usage, so an app can show what is left instead of discovering limits through errors.
  • GET /v1/library - everything your account can download, paginated, with updated_since cursoring. Open to any member who owns at least one model; browsing the full catalogue via /v1/models remains a subscriber feature.
  • GET /v1/models/{id} - full detail for one model: every approved profile with its id, variant, plate count, print time and per-filament breakdown.
  • GET /v1/categories - the categories present across the approved catalogue, each with a display label. Derived from the catalogue, so a new product line appears automatically.
  • File downloads. GET /v1/models/{id}/download returns a single-use link that expires in 5 minutes; fetch it with a plain unauthenticated GET to receive the watermarked .3mf. Requires the new downloads scope, which is opt-in per key - a key is metadata-only unless you ask for it.
  • Per-account download cap of 100 distinct models per rolling 24 hours. Re-downloading a model you already pulled in that window does not count.
  • files_updated_at on every model object - when the model's print profile FILES last changed. A description or filament edit does not move it, so an update signal built on this field does not cry wolf. Pair it with updated_since to poll for changes.
  • variant, id and isCommunity on each print profile, plus total_weight_grams and estimated_print_time_seconds on model objects - previously every client had to parse filenames and sum filaments itself.
  • API keys are no longer subscriber-only: any member who owns at least one model can create one. Request caps still differ by tier; download access follows entitlement.
  • LayerMate compatibility endpoints under /v1/compat/layermate/ implementing the LayerMate Auto-Download Source API v1.0. A translation over the endpoints above; the native API remains canonical.