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/jsonfor 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.
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:
Authorization: Bearer 3dt_pat_XXXXXXXXXXXXXXXXCreate 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.
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, 204Scopes
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.
{
"code": "plan_limit",
"message": "Your plan allows 1 project.",
"detail": { "limit": 1, "used": 1 }
}Projects
A project owns one model, its settings and every version published from it.
- GET
/api/projectsprojects:readYour projects, newest first, as { items }.
- POST
/api/projectsprojects:writeCreate a project. 402 plan_limit when the plan quota is reached.
- GET
/api/projects/{id}projects:readOne project with its settings, source and conversion report.
- PATCH
/api/projects/{id}projects:writeRename, replace settings, set the logo overlay URL.
- DELETE
/api/projects/{id}projects:write204. Removes its links, comments and stored files too.
- POST
/api/projects/{id}/transferprojects:writeMove the project into an organisation with organizationId, or back to personal with null.
- POST
/api/projects/{id}/saveprojects:writeA 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:writeMultipart model (.glb) and optional thumbnail. 413 over the size cap, 402 over the storage quota.
- GET
/api/projects/{id}/assetprojects:readThe stored GLB as binary.
- GET
/api/projects/{id}/thumbnailprojects:readJPEG 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:writeCopies the current model into a new version that never changes. The body may carry settings and a password.
- GET
/api/projects/{id}/sharesshares:readEvery published version of the project.
- GET
/api/shares/{shareId}publicPublic. 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.glbpublicPublic 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}publicFind a version by its pretty path, such as /lobby-abc123/2.
- POST
/api/shares/{shareId}/linksshares:writeAnother 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:writeChange 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:write204. The link stops working at once; other versions are untouched.
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.
# 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_requiredas no pass at all, so a viewer asks for the password again. A wrong password answers403 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.
# 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}/accesspublicBody { 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}/commentspublicThe 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}publicA visitor removes their own pin, with its photos. X-Visitor-Token header.
- GET
/api/shares/{shareId}/proposalspublicThe 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}publicA 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}/commentspublicLeave 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}/attachmentspublicAttach 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}publicOne photo, as WebP.
- GET
/api/projects/{id}/commentscomments:readEvery pin across the versions of a project. 402 when the plan does not include feedback.
- PATCH
/api/comments/{id}projects:writeSet status to open or resolved.
- DELETE
/api/comments/{id}projects:write204.
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}/proposalspublicA 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}/approvepublicThe visitor approves a design against the fixed approval sentence, with their own id in the body.
- POST
/api/shares/{shareId}/proposals/{proposalId}/unapprovepublicTake an approval back, until the designer has accepted it. X-Visitor-Token header.
- GET
/api/shares/{shareId}/proposals/{proposalId}/recordpublicThe visitor’s own copy of the record of what they agreed, as one HTML file. X-Visitor-Token header.
- GET
/api/projects/{id}/proposalscomments:readEvery proposal on a project. 402 when the plan does not include feedback.
- PATCH
/api/proposals/{id}any tokenAccept (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:readThe record for one design, as JSON, or as a self-contained HTML file with ?format=html.
- GET
/api/projects/{id}/approvalscomments:readEvery 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:writeMultipart: 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.
# 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/startpublicA 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/pollpublicpending, denied, expired, or ok with the token, which is handed over once.
- POST
/api/bridge/device/approveany tokenApprove or deny a code. Needs the signed-in session on this site, not a token; the /device page calls it.
- GET
/api/bridge/meany tokenWho 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.
# 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.
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 tokenOrganisations you belong to, with your role, seats used and the seat limit.
- POST
/api/orgsany tokenCreate one. You become the owner. 402 unless your plan includes team seats.
- GET
/api/orgs/{orgId}any tokenOne organisation with its members inline.
- GET
/api/orgs/{orgId}/invitesany tokenPending invitations. Admins only.
- POST
/api/orgs/{orgId}/invitesany tokenInvite by email. 402 when every seat is taken.
- PATCH
/api/orgs/{orgId}/members/{userId}any tokenChange a role. 403 unless you are the owner or an admin; 409 on the owner.
- DELETE
/api/orgs/{orgId}/members/{userId}any tokenRemove a member and free the seat.
- POST
/api/orgs/{orgId}/leaveany tokenLeave. 409 for the owner until ownership has moved to someone else.
- GET
/api/orgs/{orgId}/projectsany tokenEvery project in the team, visible to any member.
- GET
/api/orgs/{orgId}/usageany tokenSeats 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:manageEvents this server can send, plus the header names and the signature scheme.
- GET
/api/webhookswebhooks:manageYour 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:manageChange the URL, the event list, or pause it with active.
- POST
/api/webhooks/{id}/rotate-secretwebhooks:manageIssue a new secret and return it once. The old one stops working at once.
- POST
/api/webhooks/{id}/testwebhooks:manageDeliver one webhook.test event and report the attempt.
- GET
/api/webhooks/{id}/deliverieswebhooks:manageRecent attempts with status, response code, error and next retry.
- POST
/api/webhooks/{id}/deliveries/{deliveryId}/retrywebhooks:manageOne attempt now. 409 if it already succeeded.
- DELETE
/api/webhooks/{id}webhooks:manageDelete the endpoint and its delivery history.
These endpoints need a plan with API access; without it they answer 402 and change nothing.
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-Eventis the event id, so you can route without parsing the body.X-3DT-Deliverynames the delivery. It is also the envelopeid, so key your idempotency on it: a retry repeats the same id.X-3DT-Signatureist=<unix seconds>,v1=<hex hmac>. Furthervversions 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
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
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
chunkSizeby 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.