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

  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,
          "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_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.

ScopeDescription
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"
    }
  }
}
StatusCodeWhen
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).

Loading the reference…