API reference

API reference: publish, upload and webhooks

One HTTP API serves the web app, the editor, the plugins and your own scripts. JSON in, JSON out, camelCase on the wire.

Last updated 15 October 2026

Basics

  • Base URL. https://3dthing.in/api.
  • Content type. application/json for every request with a body, except the multipart uploads and the raw chunk PUTs.
  • Lists. Collection endpoints answer { "items": [ … ] }. The lists on this page have no cursor; they are per account and small.
  • Times. ISO 8601 strings in UTC.
  • Ids. Opaque strings. Do not parse them.
Shell
curl https://3dthing.in/api/projects \
  -H "Authorization: Bearer $THREEDT_TOKEN"

Authentication

Two ways in, equal for permissions. Browsers use the HttpOnly session cookie (3dt_session) that signing in sets. Everything else sends a personal access token as a bearer header:

Header
Authorization: Bearer 3dt_pat_XXXXXXXXXXXXXXXX

Create a token on the API tokens page, pick its scopes and an expiry, and copy the secret once. The server keeps only a sha256 hash, so a lost token is replaced, not recovered. The plugins get their own token through the device code.

A missing or revoked token answers 401 unauthorized. A valid token that your plan does not cover answers 402, not 403.

Tokens
POST /api/tokens
{ "name": "Render farm uploader", "scopes": ["projects:write"], "expiresInDays": 90 }

-> { "token": "3dt_pat_…",            // shown once, stored as sha256
     "item": { "id": "tok_…", "name": "Render farm uploader", "prefix": "3dt_pat_ab12",
               "scopes": ["projects:write"], "expiresAt": "2026-12-11T09:20:00Z",
               "lastUsedAt": null, "createdAt": "2026-09-12T09:20:00Z" } }

GET  /api/tokens          list them
GET  /api/tokens/scopes   what this server supports
DELETE /api/tokens/{id}   revoke, 204

Scopes

A token carries scopes; the server checks the scope first and your plan second. A write scope implies the matching read scope, so ask only for what you mean. A token made with no scopes keeps full account access, which is what device sign-in issues, so scope anything that goes into a script. A call outside a token's scopes answers 403 insufficient_scope. GET /api/tokens/scopes lists what a server supports.

projects:readList projects, read metadata, download the model and the thumbnail.
projects:writeCreate, rename, transfer and delete projects, replace the model asset. Implies projects:read.
shares:readList published versions with their settings, views and pretty paths.
shares:writePublish a new version and unpublish an existing one. Implies shares:read and comments:read.
comments:readRead the client feedback pins left on your shares.
webhooks:manageCreate, edit and delete webhook endpoints and read their deliveries.

Errors

Every failure has the same body, with a stable machine code and a message written for a person.

Error body
{
  "code": "plan_limit",
  "message": "Your plan allows 1 project.",
  "detail": { "limit": 1, "used": 1 }
}
400bad_requestMalformed input the schema could not accept. Some routes send a more exact code, such as bad_chunk_index or invalid_url.
401unauthorized, invalid_credentialsNo session, no token, a revoked token, or a wrong email and password.
402plan_limit, upgrade_requiredYour plan does not include this, or a quota is full. Nothing was changed or deleted.
403forbidden, insufficient_scopeNot your resource, or the token lacks the scope for this call.
403password_required, password_invalidThe link has a password and no valid pass came with the request, or the password sent to /access was wrong.
404not_foundNo such resource, or it belongs to someone else.
409too_many_links, email_exists and othersThe request clashes with the state of things: a version already has ten links, the email already has an account, a design is already accepted. The code says which.
410expired, version_unavailable, publisher_closedA link that has passed its end date, a version that newer ones replaced, or a publisher closing their account.
413too_largeOver the size cap for one file on this server.
415unsupported_media_typeAn upload that was not .glb, .gltf or .zip.
422validation_errorField level detail is in detail.
429rate_limitedSlow down and retry.
503billing_not_configured and othersAn optional part of the server is not set up here, such as payments or single sign-on.

Projects

A project owns one model, its settings and every version published from it.

  • GET/api/projectsprojects:read

    Your projects, newest first, as { items }.

  • POST/api/projectsprojects:write

    Create a project. 402 plan_limit when the plan quota is reached.

  • GET/api/projects/{id}projects:read

    One project with its settings, source and conversion report.

  • PATCH/api/projects/{id}projects:write

    Rename, replace settings, set the logo overlay URL.

  • DELETE/api/projects/{id}projects:write

    204. Removes its links, comments and stored files too.

  • POST/api/projects/{id}/transferprojects:write

    Move the project into an organisation with organizationId, or back to personal with null.

  • POST/api/projects/{id}/saveprojects:write

    A whole save in one request: multipart model, thumbnail, settings, name and the version you loaded. Everything lands together or nothing does, so a 409 leaves the project exactly as it was.

  • PUT/api/projects/{id}/assetprojects:write

    Multipart model (.glb) and optional thumbnail. 413 over the size cap, 402 over the storage quota.

  • GET/api/projects/{id}/assetprojects:read

    The stored GLB as binary.

  • GET/api/projects/{id}/thumbnailprojects:read

    JPEG or PNG thumbnail. Public once the project has a link.

Publishing and links

Publishing copies the current model and settings into a version that never changes, with its own id, a version number that goes up by one and a pretty path. The copy counts towards your storage, and a plan keeps the most recent versions of a project: older ones keep their link, their view counts and their feedback, and answer 410 with a message saying newer versions replaced them. A version anybody opened in the last 90 days is kept whatever the count says, so a link a client still opens stays safe to send.

  • POST/api/projects/{id}/publishshares:write

    Copies the current model into a new version that never changes. The body may carry settings and a password.

  • GET/api/projects/{id}/sharesshares:read

    Every published version of the project.

  • GET/api/shares/{shareId}public

    Public. A link with a password answers 403 password_required until the request carries a pass in the X-Share-Access header (see Passwords and visitors).

  • GET/api/shares/{shareId}/model.glbpublic

    Public binary, served unchanging with a one year cache. For a link with a password, use the modelUrl the read above returns: it carries the pass.

  • GET/api/shares/by-path/{slug}/{version}public

    Find a version by its pretty path, such as /lobby-abc123/2.

  • POST/api/shares/{shareId}/linksshares:write

    Another link to the same version, for a different audience: body { access, label, password, expiresAt }. access is view, configure or comment. Up to ten links per version; 409 too_many_links past that.

  • PATCH/api/shares/{shareId}shares:write

    Change a link’s access, label or end date without publishing again. A past date ends the link now; null removes it.

  • DELETE/api/shares/{shareId}shares:write

    204. The link stops working at once; other versions are untouched.

Shell
curl -X POST https://3dthing.in/api/projects/$PROJECT_ID/publish \
  -H "Authorization: Bearer $THREEDT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"startCamera": "entrance"}}'

Passwords and visitors

Neither a link's password nor a visitor's id ever travels in an address, where every server and proxy on the way would write it into a log.

A link with a password

The password is sent once, in a request body, and swapped for a pass. The pass is what the requests after it carry.

A protected link
# 1. The link asks for a password
GET /api/shares/{shareId}
-> 403 { "code": "password_required", … }

# 2. Swap the password for a pass, once. Rate limited, never cached.
POST /api/shares/{shareId}/access
{ "password": "…" }
-> { "token": "1790001800.6f1c0f6e…", "expiresAt": 1790001800 }

# 3. Send the pass in a header on the reads that follow
GET /api/shares/{shareId}
X-Share-Access: 1790001800.6f1c0f6e…
-> { …, "modelUrl": "/api/shares/{shareId}/model.glb?access=1790001800.6f1c0f6e…" }
  • A pass lasts 15 minutes, opens only the link it was made for, and stops working the moment the link's password changes.
  • A missing, expired or foreign pass answers the same 403 password_required as no pass at all, so a viewer asks for the password again. A wrong password answers 403 password_invalid.
  • The one place a pass rides in an address is modelUrl, because a model loader cannot set a header. It is short lived, and the server blanks it from its access log.
  • The owner of the link, signed in, needs no pass.

A visitor's own notes and designs

People who open a link have no account. Their browser makes up a random id of its own (a UUID) the first time they leave a note or save a design, and that id is the only thing that makes those theirs. It goes in the JSON body when a note or design is created, and in the X-Visitor-Token header on every read, delete or withdrawal after that, including the download of their own approval record.

A visitor's notes
# Leave a note: the visitor's own id goes in the JSON body
POST /api/shares/{shareId}/comments
{ "position": [0.4, 1.2, -2.1], "note": "Can this wall be oak?",
  "visitorToken": "3f2b9c1e-7a4d-4e0b-9d61-…" }

# Read, delete or take back what that visitor made: the id goes in a header
GET    /api/shares/{shareId}/comments
DELETE /api/shares/{shareId}/comments/{commentId}
X-Visitor-Token: 3f2b9c1e-7a4d-4e0b-9d61-…
  • Without the header, the lists answer with nothing of theirs (an empty list), never with anybody else's notes.
  • A note or design that is not theirs answers 404, the same as one that does not exist, so ids cannot be probed.
  • Changes and downloads without the header answer 422.
  • POST/api/shares/{shareId}/accesspublic

    Body { password }. Answers { token, expiresAt }: the pass, for 15 minutes. The only route that reads the password. Rate limited and never cached.

  • GET/api/shares/{shareId}/commentspublic

    The visitor’s own pins and the replies to them, found by the X-Visitor-Token header. An empty list without it; never anybody else’s notes.

  • DELETE/api/shares/{shareId}/comments/{commentId}public

    A visitor removes their own pin, with its photos. X-Visitor-Token header.

  • GET/api/shares/{shareId}/proposalspublic

    The visitor’s own saved designs, by the X-Visitor-Token header, and whether the project is locked, which stops new saves and approvals.

  • DELETE/api/shares/{shareId}/proposals/{proposalId}public

    A visitor deletes a design they saved. 409 accepted once the designer has accepted it. X-Visitor-Token header.

Feedback

Reviewers leave pins on a published version without an account. Each pin stores the 3D position, the surface normal and the camera pose, so the editor can fly back to the view the note was written from.

  • POST/api/shares/{shareId}/commentspublic

    Leave a pin: position, optional normal and camera pose, the configuration, a note, an optional email, the visitor’s own id, or a reply to another pin. No account needed, rate limited per address. Refused on a link below the comment level or with feedback turned off.

  • POST/api/comments/{id}/attachmentspublic

    Attach a photo to a pin, multipart. Up to four per note; each is decoded and saved again as WebP before anyone is served it.

  • GET/api/comments/{id}/attachments/{attachmentId}public

    One photo, as WebP.

  • GET/api/projects/{id}/commentscomments:read

    Every pin across the versions of a project. 402 when the plan does not include feedback.

  • PATCH/api/comments/{id}projects:write

    Set status to open or resolved.

  • DELETE/api/comments/{id}projects:write

    204.

Proposals and approvals

In a configurator, a client can save a design (a proposal) and approve one against a fixed sentence. You then accept it, after which clients can no longer save or approve options on the project’s links, decline it, or reopen it. Every one of those decisions is added to a project-wide record that is never edited: each entry carries a SHA-256 digest chained to the one before it, so an entry cannot be changed or removed without breaking the chain from that point on. The record says itself what it does not prove: we compute the digests and hold the chain, so they show that nothing was altered after it was written, not independently when it was written.

  • POST/api/shares/{shareId}/proposalspublic

    A visitor saves a design from the configurator, with their own id in the body. Up to 20 per visitor on a project.

  • POST/api/shares/{shareId}/proposals/{proposalId}/approvepublic

    The visitor approves a design against the fixed approval sentence, with their own id in the body.

  • POST/api/shares/{shareId}/proposals/{proposalId}/unapprovepublic

    Take an approval back, until the designer has accepted it. X-Visitor-Token header.

  • GET/api/shares/{shareId}/proposals/{proposalId}/recordpublic

    The visitor’s own copy of the record of what they agreed, as one HTML file. X-Visitor-Token header.

  • GET/api/projects/{id}/proposalscomments:read

    Every proposal on a project. 402 when the plan does not include feedback.

  • PATCH/api/proposals/{id}any token

    Accept (locks the project: no new saves or approvals), decline or reopen. Each decision is added to the record. 402 without feedback on the plan.

  • GET/api/proposals/{id}/recordcomments:read

    The record for one design, as JSON, or as a self-contained HTML file with ?format=html.

  • GET/api/projects/{id}/approvalscomments:read

    Every entry on the project, oldest first, so the chain can be checked end to end.

Floor plan import

The editor's floor plan import sends the drawing here and builds walls from the answer. It stores nothing and changes nothing: it reads a DXF or a vector PDF and reports walls, openings, rooms with their areas, the drawing scale it found (from a ratio such as 1:100 in the title block, a scale bar or dimension labels) and the openings it was unsure about.

  • POST/api/floorplan/parseprojects:write

    Multipart: file (.dxf or .pdf), page (for a sheet set), and drawingScale to override the scale it reads. Answers with walls, openings, rooms and the scale readings. Stores nothing.

Plugin sign-in

The plugins sign in without a password: they ask for a short code, the person approves it on this site while signed in, and the plugin receives a token of its own, named after the program and the computer. A code works for 10 minutes and gives out its token once.

Device code
# The plugin asks for a code
POST /api/bridge/device/start
-> { "deviceCode": "…", "userCode": "ABCD-1234",
     "verifyUrl": "https://3dthing.in/device", "interval": 5, "expiresIn": 600 }

# The person approves ABCD-1234 at /device while signed in on this site.
# The plugin polls every interval seconds:
POST /api/bridge/device/poll
{ "deviceCode": "…" }
-> { "status": "pending" }
-> { "status": "ok", "token": "3dt_pat_…" }     // once, then never again
  • POST/api/bridge/device/startpublic

    A new code for a plugin: deviceCode for the plugin, userCode (like ABCD-1234) for the person, the address to approve it at, and how often to poll. Rate limited.

  • POST/api/bridge/device/pollpublic

    pending, denied, expired, or ok with the token, which is handed over once.

  • POST/api/bridge/device/approveany token

    Approve or deny a code. Needs the signed-in session on this site, not a token; the /device page calls it.

  • GET/api/bridge/meany token

    Who the token belongs to, and what their plan includes.

Chunked uploads

Large scenes go up in resumable 8 MiB chunks with an optional sha256 for the whole file. This is what the Blender, 3ds Max and SketchUp plugins use, and it is the right path for anything bigger than a few megabytes or anything running over a flaky connection. Sending a chunk again is safe; ask for the upload to see which ones already landed.

Upload
# 1. announce the file
POST /api/bridge/uploads/init
{ "fileName": "lobby.glb", "sizeBytes": 48210233, "sha256": "…",
  "projectName": "Lobby v3",
  "source": { "app": "blender", "appVersion": "4.2", "renderer": "cycles", "pluginVersion": "1.0.0", "os": "win32" },
  "compression": { "geometry": "draco", "textures": "ktx2", "transport": "gzip" } }
-> { "uploadId": "up_…", "projectId": "prj_…", "chunkSize": 8388608, "totalChunks": 6,
     "receivedChunks": [], "status": "pending" }

# 2. send each 8 MiB chunk (resumable, Content-Encoding: gzip is honoured)
PUT /api/bridge/uploads/up_…/chunks/0     <raw bytes>
-> { "received": [0] }

# 2b. or, when the init answer says "direct": true, send the chunk straight to storage
#     (no gzip; the API's host charges for every byte it sends, so the big bytes skip it)
POST /api/bridge/uploads/up_…/chunks/0/url
-> { "url": "https://…", "headers": {}, "expectedBytes": 8388608, "expiresInSeconds": 900 }
PUT <url>                                 <raw bytes>

# after a dropped connection, ask what already landed
GET /api/bridge/uploads/up_…
-> { …, "receivedChunks": [0, 1, 2], "status": "uploading" }

# 3. assemble
POST /api/bridge/uploads/up_…/complete
-> { "project": { … }, "assetKey": "projects/prj_…/model.glb", "sizeBytes": 48210233 }

Scope: projects:write. The upload creates the project when you pass projectName instead of projectId, and records the source program, renderer and conversion report on it. Uploading also needs a plan that includes sending from the plugins; without it the init call answers 402.

Analytics

Daily view counts for every published version of a project, plus the sites that linked to them.

Analytics
GET /api/projects/{id}/analytics?days=90

{
  "projectId":  "prj_…",
  "totalViews": 412,
  "windowDays": 90,
  "since":      "2026-06-14T00:00:00Z",
  "days":      [ { "day": "2026-06-15", "views": 7 }, … ],
  "versions":  [ { "version": 3, "shareId": "shr_…", "views": 310 }, … ],
  "referrers": [ { "host": "mail.google.com", "views": 96 }, … ]
}

Scope: projects:read. The window is capped at 365 days; ask for what you need with ?days=. A server with analytics switched off answers 404, and the project page shows its empty state.

Organisations

Organisations group projects under a shared pool of seats. Roles are owner, admin, member and viewer.

  • GET/api/orgsany token

    Organisations you belong to, with your role, seats used and the seat limit.

  • POST/api/orgsany token

    Create one. You become the owner. 402 unless your plan includes team seats.

  • GET/api/orgs/{orgId}any token

    One organisation with its members inline.

  • GET/api/orgs/{orgId}/invitesany token

    Pending invitations. Admins only.

  • POST/api/orgs/{orgId}/invitesany token

    Invite by email. 402 when every seat is taken.

  • PATCH/api/orgs/{orgId}/members/{userId}any token

    Change a role. 403 unless you are the owner or an admin; 409 on the owner.

  • DELETE/api/orgs/{orgId}/members/{userId}any token

    Remove a member and free the seat.

  • POST/api/orgs/{orgId}/leaveany token

    Leave. 409 for the owner until ownership has moved to someone else.

  • GET/api/orgs/{orgId}/projectsany token

    Every project in the team, visible to any member.

  • GET/api/orgs/{orgId}/usageany token

    Seats used, the seat limit, members, pending invitations and the project count.

Seats count accepted members plus invitations that have not expired, so revoking a pending invitation frees its seat at once. Move an existing project into a team with POST /api/projects/{id}/transfer and a body of { "organizationId": "org_…" }; send null to move it back to personal.

Webhooks

Register an endpoint on the webhooks page, choose events, and 3D Thing POSTs a signed JSON body when they happen. Answer 2xx quickly and do the work afterwards: a slow handler is retried as a failure. Retries back off exponentially, and each attempt is listed with its status and response so you can send one again by hand.

project.createdA project was created in the web app, from the command line, or by a send from Blender, 3ds Max or SketchUp.
project.updatedName, settings, logo or the model asset changed.
project.publishedA new immutable version and its link were created.
share.viewedSomeone opened a published version.
comment.createdA reviewer left a pin with a note on a published version.
upload.completedA chunked upload from Blender, 3ds Max or SketchUp assembled successfully.
  • GET/api/webhooks/eventswebhooks:manage

    Events this server can send, plus the header names and the signature scheme.

  • GET/api/webhookswebhooks:manage

    Your endpoints.

  • POST/api/webhookswebhooks:manage

    { url, events, description? }. The answer carries the signing secret exactly once. Addresses on private networks are refused.

  • PATCH/api/webhooks/{id}webhooks:manage

    Change the URL, the event list, or pause it with active.

  • POST/api/webhooks/{id}/rotate-secretwebhooks:manage

    Issue a new secret and return it once. The old one stops working at once.

  • POST/api/webhooks/{id}/testwebhooks:manage

    Deliver one webhook.test event and report the attempt.

  • GET/api/webhooks/{id}/deliverieswebhooks:manage

    Recent attempts with status, response code, error and next retry.

  • POST/api/webhooks/{id}/deliveries/{deliveryId}/retrywebhooks:manage

    One attempt now. 409 if it already succeeded.

  • DELETE/api/webhooks/{id}webhooks:manage

    Delete the endpoint and its delivery history.

These endpoints need a plan with API access; without it they answer 402 and change nothing.

A delivery
POST https://your-server.example/hooks/3dthing
X-3DT-Event: project.published
X-3DT-Delivery: dlv_01HZY…
X-3DT-Signature: t=1789251600,v1=6f1c0f6e…
Content-Type: application/json

{
  "id": "dlv_01HZY…",
  "type": "project.published",
  "createdAt": "2026-09-12T09:20:00Z",
  "data": {
    "projectId": "prj_…",
    "name": "Lobby v3",
    "ownerId": "usr_…",
    "shareId": "shr_…",
    "version": 3
  }
}
  • X-3DT-Event is the event id, so you can route without parsing the body.
  • X-3DT-Delivery names the delivery. It is also the envelope id, so key your idempotency on it: a retry repeats the same id.
  • X-3DT-Signature is t=<unix seconds>,v1=<hex hmac>. Further v versions may be added; ignore the ones you do not know.

How do I verify a webhook signature?

The signed value is the timestamp, a full stop, then the raw request body exactly as it arrived. Compute HMAC-SHA256 with the endpoint's signing secret, compare in constant time, and reject anything older than five minutes so a captured request cannot be replayed. Verify before parsing, and never re-serialise the body first: any reformatting changes the bytes and breaks the comparison.

Node

Node
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.THREEDT_WEBHOOK_SECRET;
const TOLERANCE_S = 300;

// The raw body is what was signed, so parse it as a Buffer, not as JSON.
app.post('/hooks/3dthing', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-3DT-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const timestamp = Number(parts.t);
  const signature = parts.v1 ?? '';

  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_S) {
    return res.status(400).send('stale timestamp');
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${parts.t}.${req.body.toString('utf8')}`)
    .digest('hex');

  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(event.type, event.data);
  res.status(200).send('ok');   // answer 2xx quickly, then do the work
});

Python

Python
import hashlib, hmac, os, time
from fastapi import APIRouter, Header, HTTPException, Request

router = APIRouter()
SECRET = os.environ["THREEDT_WEBHOOK_SECRET"].encode()
TOLERANCE_S = 300

@router.post("/hooks/3dthing")
async def receive(request: Request, x_3dt_signature: str = Header("")):
    raw = await request.body()                       # verify before parsing
    parts = dict(kv.split("=", 1) for kv in x_3dt_signature.split(",") if "=" in kv)
    try:
        timestamp = int(parts.get("t", ""))
    except ValueError:
        raise HTTPException(400, "bad timestamp")
    if abs(time.time() - timestamp) > TOLERANCE_S:
        raise HTTPException(400, "stale timestamp")

    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(400, "bad signature")

    event = await request.json()
    print(event["type"], event["data"])
    return {"ok": True}

Limits

  • Plan quotas. Projects, storage and links per project come from your plan. Over a quota the API answers 402 and changes nothing.
  • Upload chunk size. 8 MiB, reported as chunkSize by the init call. Use what the server tells you rather than assuming.
  • Model size. Capped per server; over it you get 413.
  • Notes and designs from visitors. Rate limited by address, so a public link cannot be flooded. A visitor keeps up to 20 designs per project.
  • Published model files. Served unchanging with a one year cache, so they are cheap to embed and safe behind a CDN.

Something missing here that you need? Tell us what you are building, at hello@3dthing.in.