API documentation
The API lets scripts and other tools read and change your shelf and your print requests: the same filaments, spools, locations and requests you see in the app, under the same rules. It isn’t part of any plan at the moment.
Getting started
- Create a token in Settings → API & webhooks. You’re asked to sign in again if you haven’t in the last ten minutes. Give it a name, the scopes it needs, and how long it lasts — a year at most. The token is shown once: copy it then. It can’t be shown again, only revoked.
- Keep it somewhere safe, such as an environment variable, and list your filaments:
curl "https://your-site.example/api/v1/filaments?perPage=1" \ -H "Authorization: Bearer $TOKEN" - The answer is a page of filaments, each with its spools:
{ "items": [ { "id": "c879fad4-a08a-486e-a399-c4b57451d56f", "name": "Jet Black", "brand": "Prusament", "diameterMm": 1.75, "officialUrl": null, "category": { "id": "fc32738f-439e-427d-99d0-d1cf27a5d29e", "name": "PETG" }, "colorName": null, "colors": [ "#222222" ], "colorMode": "solid", "finish": null, "effects": [], "colorFamily": "black", "totalGrams": 900, "spoolCount": 1, "lastDriedAt": null, "dryOverdue": false, "lowStockThresholdGrams": null, "effectiveLowStockGrams": null, "lowStockSince": null, "reorderedAt": null, "averageCostPerGram": null, "inventoryValueMinor": 0, "unpricedSpools": 1, "materialFamily": "petg", "filler": "none", "defaultEmptySpoolGrams": null, "manufacturerFoodContactStatement": false, "catalog": null, "spools": [ { "id": "a8d7153b-1197-473f-be36-7e146bdc1d8f", "filamentId": "c879fad4-a08a-486e-a399-c4b57451d56f", "status": "sealed", "form": "spool", "initialNetGrams": 1000, "remainingGrams": 900, "emptySpoolGrams": null, "locationId": null, "location": null, "purchasePriceMinor": null, "costPerGram": null, "lotNumber": null, "purchasedAt": null, "openedAt": null, "lastDriedAt": null, "shortCode": "V62GATPZCK", "notes": null, "dryDueAt": null, "dryOverdue": false, "dryingSince": null, "createdAt": "2026-09-27T22:13:35.124Z", "updatedAt": "2026-09-27T22:13:35.124Z" } ], "photos": [], "createdAt": "2026-09-27T22:13:35.124Z", "updatedAt": "2026-09-27T22:13:35.124Z" } ], "page": 1, "perPage": 1, "total": 2, "totalPages": 2, "inventory": { "currency": "EUR", "valueMinor": 0, "unpricedSpools": 2 } }
Every operation, with an example, is in the reference below. To try calls with your token in the browser, use the interactive page at /api/v1/docs.
Authentication
Every call sends the token in the Authorization header:
Authorization: Bearer <token>- The API never reads a cookie, and never takes a token in the address: a query string ends up in logs and browser history.
- A token lasts a year unless you chose less. Revoke one in Settings → API & webhooks at any time; it stops working at once.
- If your plan stops including the API, calls answer
403 feature_not_in_planand the token is kept. It works again when your plan includes the API. - The API is for scripts and servers. Pages on other sites can’t call it from a browser (CORS refuses them), and a token must never be put in one.
Scopes
A token carries one or more of four scopes, and each operation needs one. The reference names it beside every operation.
| Scope | Description |
|---|---|
| inventory:read | Read filaments, their spools and each spool’s ledger, and locations. |
| inventory:write | Add and change filaments, spools and locations, and record what is left on a spool. |
| requests:read | Read print requests, with their items and history. |
| requests:write | Change a request’s status, by the same rules as the app. |
GET /api/v1/me answers to any of them: it says which workspace and scopes the token has.
Limits
- Each token may make 60 requests a minute. Past that, the answer is
429 rate_limiteduntil the minute is up; then the count starts again. - Each address also shares the site’s general limit of 300 requests a minute. Its answers carry
x-ratelimit-limitandx-ratelimit-remaining; once it’s reached, the 429 carriesretry-after, in seconds. - Lists come in pages of at most 100.
Errors
Every error has the same shape: a code to act on, and a message for a person.
{
"error": {
"code": "insufficient_scope",
"message": "This token does not have the inventory:write scope.",
"details": {
"scope": "inventory:write"
}
}
}| Status | Code | When |
|---|---|---|
| 400 | validation_failed | The query or the body doesn't match the operation. `details` lists each field as `{ path, message }`. |
| 401 | unauthorized | No `Authorization: Bearer` header, or a token that isn't valid: mistyped, revoked or expired. |
| 403 | insufficient_scope | The token is valid but wasn't given the scope this operation needs. `details.scope` names it. |
| 403 | feature_not_in_plan | The workspace's plan doesn't include the API (`details.feature` is `api_access`), or a field that needs another feature, such as a location or a price. The token is kept, and works again when the plan does. |
| 403 | spool_limit_reached | Adding a spool would take the workspace past its plan's spool limit. `details` has the limit and the count. |
| 403 | account_suspended | The account that created the token is suspended. |
| 404 | not_found | No such thing in this workspace. Another workspace's id answers exactly the same. |
| 409 | location_exists | A location with that name exists already. |
| 409 | invalid_transition | A request can't move from its status to that one. The app's rules, the same for the API. |
| 409 | status_unchanged | The request already has that status. |
| 409 | request_closed | The request is delivered, declined or cancelled, and can't change any more. |
| 429 | rate_limited | Over 60 requests in a minute from this token, or over the per-address limit. Wait for the minute to pass. |
| 503 | maintenance | The platform is in maintenance. `message` says why and, when known, until when. Try again later. |
| 503 | not_ready | A release is being put in place. Try again in a moment. |
Conventions
- Ids are UUIDs, as strings.
- Times are ISO 8601 in UTC, such as
2026-09-28T10:00:00.000Z. A calendar date, such as when a spool was bought, isYYYY-MM-DD. - Weights are whole grams.
- Money is a whole number in the currency’s smallest unit — cents, for euros — in your workspace’s currency (
currencyonGET /api/v1/me). It is there only when your plan includes cost tracking, and null otherwise. - Lists take
pageandperPage, and answeritems,page,perPage,totalandtotalPages. - Null means not set, not known, or not in your plan — never zero.
/v1grows by adding: new fields and new operations can appear at any time. Ignore fields you don’t know.
Versioning
/v1 is a contract. A change that would break a client — a field renamed, removed or given a new meaning, an operation removed — ships as /v2, beside it.
Webhooks
A webhook sends an event to your server as it happens. Add one in Settings → API & webhooks: an https address and the events it wants. Its secret is shown once, when you add it.
Every delivery is a POST of JSON, { id, event, createdAt, data }. data carries ids, a status and a link into the API — never a person’s name or address; read what you need from the API.
request.created
{
"id": "bf15b7d8-8d28-42e0-89df-7b4a0f85358c",
"data": {
"url": "https://your-site.example/api/v1/requests/94657819-bf36-4e27-9cfa-126a5ac0a336",
"status": "new",
"createdAt": "2026-09-27T22:13:36.593Z",
"requestId": "94657819-bf36-4e27-9cfa-126a5ac0a336"
},
"event": "request.created",
"createdAt": "2026-09-27T22:13:36.595Z"
}request.status_changed
{
"id": "a8085fb3-5200-464b-9a7d-d34974b0f3b2",
"data": {
"url": "https://your-site.example/api/v1/requests/94657819-bf36-4e27-9cfa-126a5ac0a336",
"status": "accepted",
"changedAt": "2026-09-27T22:13:36.626Z",
"requestId": "94657819-bf36-4e27-9cfa-126a5ac0a336",
"previousStatus": "new"
},
"event": "request.status_changed",
"createdAt": "2026-09-27T22:13:36.629Z"
}request.estimate_acknowledged
{
"id": "39f5e38e-87ab-429a-a1e0-818ea815280b",
"data": {
"url": "https://your-site.example/api/v1/requests/94657819-bf36-4e27-9cfa-126a5ac0a336",
"requestId": "94657819-bf36-4e27-9cfa-126a5ac0a336",
"acknowledgedAt": "2026-09-27T22:13:36.707Z",
"estimateVersion": 1
},
"event": "request.estimate_acknowledged",
"createdAt": "2026-09-27T22:13:36.711Z"
}stock.low
{
"id": "b196aabf-1c9e-4cb1-91a7-f8fadc6aa914",
"data": {
"at": "2026-09-27T22:13:36.659Z",
"url": "https://your-site.example/api/v1/filaments/46755d2e-bf4f-436d-ab26-7053e05c3abe",
"filamentId": "46755d2e-bf4f-436d-ab26-7053e05c3abe",
"remainingGrams": 850,
"thresholdGrams": 900
},
"event": "stock.low",
"createdAt": "2026-09-27T22:13:36.666Z"
}spool.updated
{
"id": "77995c4e-fbcd-455f-9e6f-725c400263d8",
"data": {
"url": "https://your-site.example/api/v1/spools/750677e9-b953-445b-a506-9d27a3775fbc",
"status": "sealed",
"spoolId": "750677e9-b953-445b-a506-9d27a3775fbc",
"updatedAt": "2026-09-27T22:13:36.645Z",
"filamentId": "46755d2e-bf4f-436d-ab26-7053e05c3abe",
"remainingGrams": 850
},
"event": "spool.updated",
"createdAt": "2026-09-27T22:13:36.650Z"
}Send test event, beside a webhook in Settings, delivers a ping to that one address:
{
"id": "98d09f8d-2c05-497f-a319-908c43d79d5b",
"data": {
"message": "If you can read this, the endpoint and the signature both work.",
"workspaceId": "2f218b47-caeb-432c-af82-889ea16d0264"
},
"event": "ping",
"createdAt": "2026-09-27T22:13:36.727Z"
}Checking the signature
Every delivery is signed. The X-3DPrintMe-Signature header is t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of <t>.<body>, the raw body as it arrived, with your webhook’s secret. Compute it and compare, and reject a delivery whose t is more than 5 minutes from your clock, so a captured delivery can’t be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}For example, with the secret below, this body at this time carries this header:
secret: whsec_exampleOnlyDoNotUse0000000000000
t: 1790575200
body: {"id":"bf15b7d8-8d28-42e0-89df-7b4a0f85358c","data":{"url":"https://your-site.example/api/v1/requests/94657819-bf36-4e27-9cfa-126a5ac0a336","status":"new","createdAt":"2026-09-27T22:13:36.593Z","requestId":"94657819-bf36-4e27-9cfa-126a5ac0a336"},"event":"request.created","createdAt":"2026-09-27T22:13:36.595Z"}
X-3DPrintMe-Signature: t=1790575200,v1=476454e0d58b0512fc69cdf231d43d3e7ceb63a09212333e2eb47e53bfdb6f7dRetries
Answer with any 2xx within 10 seconds. Otherwise the delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours — 6 attempts in all. After 20 failed attempts in a row, the webhook is switched off and you’re told; switch it on again in Settings once your server is back.
Reference
Every operation, from the API’s own description (the raw one is at /api/v1/docs/json).
Loading the reference…