API documentation
The API lets scripts and other tools read and change your shelf and your orders: the same filaments, spools, locations and orders you see in the app, under the same rules. Orders are called requests in the API. It isn’t part of any plan at the moment.
La documentación para desarrolladores está en inglés.
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, "untrackedSpools": 0, "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, "hiddenFromLibrary": false, "catalog": null, "spools": [ { "id": "a8d7153b-1197-473f-be36-7e146bdc1d8f", "filamentId": "c879fad4-a08a-486e-a399-c4b57451d56f", "status": "sealed", "form": "spool", "initialNetGrams": 1000, "remainingGrams": 900, "amountTracked": true, "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, "untrackedSpools": 1 } }
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.
| Ámbito | Descripción |
|---|---|
| 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"
}
}
}| Estado | Código | Cuándo |
|---|---|---|
| 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).
Cargando la referencia…