Thema

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.

De documentatie voor ontwikkelaars is in het Engels.

Getting started

  1. 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.
  2. 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"
  3. 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_plan and 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.

ScopeBeschrijving
inventory:readRead filaments, their spools and each spool’s ledger, and locations.
inventory:writeAdd and change filaments, spools and locations, and record what is left on a spool.
requests:readRead print requests, with their items and history.
requests:writeChange 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_limited until 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-limit and x-ratelimit-remaining; once it’s reached, the 429 carries retry-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"
    }
  }
}
StatusCodeWanneer
400validation_failedThe query or the body doesn't match the operation. `details` lists each field as `{ path, message }`.
401unauthorizedNo `Authorization: Bearer` header, or a token that isn't valid: mistyped, revoked or expired.
403insufficient_scopeThe token is valid but wasn't given the scope this operation needs. `details.scope` names it.
403feature_not_in_planThe 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.
403spool_limit_reachedAdding a spool would take the workspace past its plan's spool limit. `details` has the limit and the count.
403account_suspendedThe account that created the token is suspended.
404not_foundNo such thing in this workspace. Another workspace's id answers exactly the same.
409location_existsA location with that name exists already.
409invalid_transitionA request can't move from its status to that one. The app's rules, the same for the API.
409status_unchangedThe request already has that status.
409request_closedThe request is delivered, declined or cancelled, and can't change any more.
429rate_limitedOver 60 requests in a minute from this token, or over the per-address limit. Wait for the minute to pass.
503maintenanceThe platform is in maintenance. `message` says why and, when known, until when. Try again later.
503not_readyA 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, is YYYY-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 (currency on GET /api/v1/me). It is there only when your plan includes cost tracking, and null otherwise.
  • Lists take page and perPage, and answer items, page, perPage, total and totalPages.
  • Null means not set, not known, or not in your plan — never zero.
  • /v1 grows 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=476454e0d58b0512fc69cdf231d43d3e7ceb63a09212333e2eb47e53bfdb6f7d

Retries

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).

De referentie wordt geladen…