REST API
The station serves a JSON API on its local network. The browser page uses the same API.
- Base URL:
http://<station>/api/v1(http://opentag-station.local/api/v1with the default hostname). Plain HTTP only; there is no HTTPS listener. - The route table is
routesinsrc/web/api_router.hpp. - Paths are exact. A
?or#in the path is rejected with400 invalid_request.
Authentication
| Request | Rule |
|---|---|
GET routes, WebSocket |
Never need a token. |
POST / PATCH routes |
Need Authorization: Bearer <token> when web.access_token is set. When it is empty, every request on the network is accepted. |
POST /network/scan, POST /network/connect |
Also accepted without a token from a client joined to the setup network while setup mode is active. |
The token is empty, or 16–128 characters of A–Z a–z 0–9 - . _ ~. The header value must be
Bearer, one space, then the token with no further whitespace. A failed check returns
401 authentication_required with WWW-Authenticate: Bearer.
How to set the token: Access token and network safety.
Requests and responses
Responses carry Content-Type: application/json; charset=utf-8, Cache-Control: no-store and
X-Content-Type-Options: nosniff.
{"api_version": "v1", "ok": true, "data": {}}
{"api_version": "v1", "ok": false,
"error": {"code": "validation_failed", "message": "…", "retryable": false}}
error.message is at most 512 bytes.
Read requests
GET requests must not have a body (400 unexpected_body).
Change requests
Every POST and PATCH except the firmware upload needs these headers, each exactly once:
| Header | Value |
|---|---|
Content-Type |
application/json (optionally ; charset=utf-8) |
X-OpenTag-Request |
web |
Idempotency-Key |
1–64 characters of A–Z a–z 0–9 - _ . : |
The body is one JSON object, nested at most 8 levels. Unknown fields are rejected. Routes that
take no parameters need the empty object {}.
An accepted change returns 202 with a receipt; the work happens afterwards:
{"api_version": "v1", "ok": true,
"data": {"operation_id": 17, "kind": "scale_weigh", "state": "queued"}}
Poll GET /operations/{id} until state is succeeded, failed or confirmation_required.
The station keeps the 24 most recent operations in memory; older ones and all operations from
before a restart return 404 operation_not_found.
Idempotency
The station remembers up to 32 keys for ten minutes each, in memory only.
| Situation | Result |
|---|---|
| Same key, same method, path and body, within ten minutes | 202 with the original operation_id; nothing runs twice. |
| Same key, different request | 409 state_conflict |
| All 32 slots full | The oldest key whose operation has finished is dropped to make room. Keys of queued or running operations are never dropped. |
| All 32 slots hold keys of queued or running operations | 409 state_conflict, retryable: true, message "The station is busy. Wait a minute and try again." |
POST /tag-writer with action catalog (a Spoolman catalog page) never takes a slot: it still
needs an Idempotency-Key, but a repeat runs again instead of being replayed.
If a receipt is lost, do not send the request again under a new key. Poll the operation if you
have its ID, until it finishes or returns 404. If no ID was received, repeat the identical
request with the same key, or wait until the ten-minute window has passed.
Routes
"Token" means the bearer rule above applies. Body limits are in bytes.
Status and diagnostics
| Method and path | Auth | Response data |
|---|---|---|
GET /status |
– | system, backends.spoolman, backends.filabridge, spool_generation, printer_revision, operations_revision |
GET /device |
– | device (hostname, hardware_id, ip_address, local_url), build (version, git_sha, build_date, …) |
GET /health |
– | status (healthy / degraded / unhealthy), local_services_ready, backend_degraded, local_api_authentication_enabled, local_browser_control_enabled, nfc_available |
GET /diagnostics |
– | system, scale, backends, queues, operations, ota_owner_ready, nfc_available |
GET /logs |
– | logs[] (sequence, uptime_ms, level, source, message, truncated, redacted), oldest_cursor, latest_cursor, dropped_count. At most 32 entries, 192 bytes each, lost on restart. |
GET /operations/{id} |
– | operation_id, kind, state, created_at_ms, updated_at_ms, message, optional error (category, message, retryable). id is a positive decimal without leading zeros. |
POST /backends/test |
Token | Body {} (≤ 256). Re-checks Spoolman and FilaBridge. |
Operation state is one of queued, running, succeeded, failed, confirmation_required.
The kind in an operation record can differ from the kind in the receipt (for example
weight_update for a scale_update receipt, firmware_reboot for update_reboot); match
operations by operation_id.
Network
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /network |
– | – | system, networks[] (ssid, rssi_dbm, secured), config_revision, hostname, access_token_configured, local_browser_control_enabled |
POST /network/scan |
Token or setup network | {} (≤ 256) |
Results appear in GET /network. |
POST /network/connect |
Token or setup network | ≤ 1024, see below | Saves Wi-Fi settings and joins. |
POST /network/setup-mode |
Token | {} or {"stop": true} (≤ 256) |
{} starts the setup network (receipt kind network_setup_mode); it closes by itself after 10 minutes. {"stop": true} stops a setup network started this way (receipt kind network_setup_stop); refused with 409 while the setup network runs because no Wi-Fi is saved or connecting keeps failing. Any other body returns 400. |
Setup network state is in system.network.provisioning of GET /network: active, reason
(unconfigured, connection_failures, requested or none), grace_remaining_ms (time left
after a successful join) and requested_remaining_ms (time left before a setup network started on
request closes; 0 otherwise).
POST /network/connect fields:
| Field | Required | Rule |
|---|---|---|
expected_revision |
yes | config_revision from GET /network; a stale value returns 409. |
ssid |
yes | 1–32 bytes |
password |
no | ≤ 64 bytes. Omitted with a new ssid: the saved password is cleared. Omitted with the same ssid: kept. |
hostname |
no | 1–63 characters of a–z 0–9 -, not starting or ending with - |
access_token |
no | 16–128 characters. A setup-network client cannot replace a token that already exists (422). |
Scale
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /scale |
– | – | scale, command_queue_depth, weigh_sync (measurement_id, phase, message, spool_id, can_update, …) |
POST /scale/weigh |
Token | {} (≤ 256) |
Starts one measurement. |
POST /scale/update |
Token | {"measurement_id": n} (≤ 256) |
Saves that measurement to Spoolman. n is the positive weigh_sync.measurement_id; no other field is allowed. Each measurement can be saved once. |
POST /scale/tare |
Token | {} (≤ 256) |
Sets zero from the empty platform. |
POST /scale/calibrate |
Token | {"reference_grams": n} (≤ 512) |
0 < n ≤ 5000. Tare first. |
Rules and tolerances: Scale behaviour and Spoolman integration.
Tag and spool
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /nfc |
– | – | Reader and tag state. state is deferred, initializing, idle, stabilizing, reading, openprinttag, blank_compatible, unsupported, unsupported_technology, multiple or error. technology is nfcv, nfca or null. For an NFC-A tag (unsupported_technology, lifecycle UNSUPPORTED_TYPE) uid is its NFCID1 and nfca holds nfcid1, length, sak, atqa, kind (type2, mifare_classic, type4, other) and description; nothing is read from it. |
GET /nfc/tag |
– | – | The same state under tag. |
GET /spool |
– | – | workflow: tag_lifecycle, stage, spool_generation, printer_revision, tag, spoolman, filabridge, candidates[], spool, reconciliation, optional error |
POST /spool/confirm |
Token | ≤ 512 | Exactly spool_generation (> 0), spool_id (> 0) and confirmed: true. Confirms one of candidates[] as the spool for the tag on the station. Does not need a weight on the scale; fails with "Spool changed; refresh before confirming" if the tag changed. |
GET /tag-writer |
– | – | Current writer view: phase, message, operation_id, completed_blocks, total_blocks and the data of the last action. |
POST /tag-writer |
Token | ≤ 4096 | action plus the fields of that action, below. |
POST /tag-writer actions:
action |
Fields | Purpose |
|---|---|---|
catalog |
entity (vendor / filament / spool), offset, optional search, search_field (name / vendor), material, article_number, vendor_id, filament_id |
One page of the Spoolman catalog. |
create_spool |
spool: object with filament_id (> 0) and optional initial_weight, remaining_weight or used_weight (not both), spool_weight, price, location, lot_nr, comment |
Create a spool in Spoolman. |
preview |
spool_id (> 0), optional mode (update writes only the consumed weight) |
Read the tag on the station and build the content to write for that spool. |
write |
uid, generation, target_checksum (strings), spool_id (> 0), previous_spool_id (≥ 0) |
Write the previewed content to the tag and link it. |
retry_association |
none | Retry the Spoolman link after a completed write. |
clear_preview |
none | Prepare a clear. |
clear |
exactly uid, generation, current_checksum, target_checksum (strings) |
Clear the tag and unlink it. |
retry_unlink |
none | Retry the Spoolman unlink after a completed clear. |
discard_recovery |
exactly confirm: "SKIP RECOVERY" and the tag's uid |
Abandon an interrupted operation. Refused when the recovery belongs to another tag. |
update_spool |
spool_id (> 0), expected, changes (non-empty) |
Edit a spool in Spoolman. |
update_filament |
filament_id (> 0), expected, changes (non-empty), optional spool_id (> 0) |
Edit a filament in Spoolman. |
A catalog reply in GET /tag-writer carries entity, offset, limit (rows asked for: 8, or
fewer when a page of 8 was too large for the station and the same offset was asked again with 4,
2 or 1), next_offset, has_more, search_field (vendor when a name search found nothing and
the station searched by vendor name instead) and items[]. Page on with offset = next_offset.
A preview reply carries dropped_fields[]: one object per value that will not be written to the
tag, with field (name, vendor, material, material_type, article_number or color),
value and reason (for example longer than 7 characters). The matching sentence is also in
warnings[]. An update preview has none.
There is no route that writes raw tag memory. The values for write and clear come from the
preceding preview in GET /tag-writer. Engine details:
tag-writer.md.
Printer and toolheads
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /printers |
– | – | revision, printers[] |
GET /toolheads |
– | – | revision, toolheads[] (printer_id, printer_state, backend_id, display_number, display_name, assigned_spool_id, profile_enabled, profile_name) |
POST /toolheads/{id}/assign |
Token | ≤ 2048 | id is the 0-based toolhead, 0–4. |
POST /toolheads/{id}/unassign |
Token | ≤ 2048 | Same id rule. |
Assign needs all of these fields and no others. Unassign takes the same set without
expected_spool_id and replace_occupied_confirmed.
| Field | Rule |
|---|---|
printer_id |
1–128 bytes, from GET /printers |
expected_spool_id |
> 0; the spool to assign |
expected_current_spool_id |
Spool now on the toolhead, or null if empty. Unassign: must be > 0. |
expected_printer_state |
unknown, idle, printing, paused, attention, finished, stopped, error, offline or not_configured |
spool_generation |
From GET /spool |
printer_revision |
From GET /printers |
replace_occupied_confirmed |
true to replace a spool already on the toolhead |
advanced_override |
true to change a toolhead while the printer is printing, paused, needs attention, or its state is unknown |
Behaviour: FilaBridge integration.
Configuration
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /config |
– | – | Settings without secrets, plus revision. |
PATCH /config |
Token | ≤ 16384 | expected_revision plus at least one section. |
{"expected_revision": 12,
"spoolman": {"url": "http://192.168.1.20:7912"},
"reconciliation": {"auto_update_after_weigh": true}}
Sections: device, wifi, web, spoolman, filabridge, scale_profile, toolheads,
reconciliation. An object section that is present must not be empty. toolheads is a list; an
empty list removes every profile. An omitted field keeps its value.
A stale expected_revision returns 409 state_conflict. Keys, defaults, bounds and the redaction
rules: Configuration keys.
Firmware update
| Method and path | Auth | Body | Notes |
|---|---|---|---|
GET /update |
– | – | state, generation, revision, operation_id, upload_operation_id, expected_sha256, calculated_sha256, image_size, received_bytes, progress, partitions, current, candidate, capabilities, optional last_error |
POST /update/upload |
Token | raw image, ≤ 5 MiB | Headers below. Not JSON. |
POST /update/reboot |
Token | ≤ 512 | Restart into the uploaded image. |
POST /update/cancel |
Token | ≤ 512 | Discard an upload in progress or a validated image that has not been started. |
Upload headers:
| Header | Value |
|---|---|
Content-Type |
application/octet-stream |
Content-Length |
1 to 5,242,880 |
X-OpenTag-Request |
web |
Idempotency-Key |
as for other change requests |
X-OpenTag-Image-SHA256 |
SHA-256 of the file, 64 lowercase hex characters |
X-OpenTag-Expected-Generation |
generation from GET /update, decimal without leading zeros |
The upload answers when the image has been written and checked: 200 with operation_id,
upload_operation_id, kind: "firmware_upload", state: "succeeded", generation,
update_state, validated, activated. Repeating the same upload with the same key returns
202 with state: "duplicate". The upload fails if no data arrives for 5 seconds or the whole
transfer exceeds 180 seconds (408 upload_timeout).
Reboot and cancel take exactly these four fields and act only on the upload they name:
{"upload_operation_id": 21, "expected_generation": 4,
"expected_sha256": "<64 lowercase hex>", "confirmation": "REBOOT INTO UPDATE"}
For cancel the confirmation is CANCEL UPDATE. A mismatch with the current upload returns
409 state_conflict. States and rollback: Firmware update internals.
Device control
| Method and path | Auth | Body |
|---|---|---|
POST /device/reboot |
Token | {"confirmation": "REBOOT"} (≤ 256) |
POST /device/factory-reset |
Token | {"confirmation": "FACTORY RESET"} (≤ 256) |
Factory reset deletes the saved settings, Wi-Fi credentials and scale calibration. It does not
change the firmware. Reboot, factory reset and update reboot are refused with 409 while a tag
or Spoolman operation is queued or running.
Static pages
GET /, /assets/app.css, /assets/app.js and /assets/writer.js serve the browser page. Any
other path returns 404 route_not_found, except for a setup-network client, which is redirected
(302) to http://192.168.4.1/.
Live events
ws://<station>/api/v1/events is a WebSocket that only sends. No token is needed. Any data frame
from the client closes the connection. At most two clients are served at once. Messages are JSON,
at most 4096 bytes:
type |
data |
When |
|---|---|---|
scale |
Same object as scale in GET /scale |
During a measurement, at most every 500 ms, and once when it ends |
update |
Same object as GET /update |
When the update state changes, at most every 500 ms |
invalidate |
{"resource": "backends" \| "nfc" \| "scale" \| "update"} |
Fetch that resource again |
heartbeat |
{"uptime_ms": n} |
Every 15 seconds |
Status and error codes
| HTTP | error.code |
|---|---|
| 400 | invalid_request, invalid_request_headers, unexpected_body, incomplete_request_body, invalid_operation_id, invalid_firmware_length, invalid_upload_headers, invalid_upload_precondition, incomplete_firmware_upload |
| 401 | authentication_required |
| 404 | route_not_found, unsupported_api_version, operation_not_found |
| 405 | method_not_allowed (with an Allow header) |
| 408 | request_timeout, upload_timeout |
| 409 | state_conflict, tag_state_conflict, scale_unstable |
| 413 | request_too_large, firmware_too_large |
| 415 | unsupported_media_type (upload only) |
| 422 | validation_failed, invalid_tag, firmware_validation_failed |
| 500 | internal_error, internal_route_error, invalid_snapshot, unsafe_configuration_snapshot, response_too_large, streaming_transport_required |
| 502 | backend_authentication_failed, backend_api_changed, invalid_backend_response |
| 503 | network_unavailable, backend_unavailable, nfc_unavailable, scale_unavailable, update_unavailable, operation_not_queued, resource_unavailable |
| 507 | persistence_failed |
A 503 resource_unavailable with retryable: true means the station was briefly out of working
memory; repeat the request with the same Idempotency-Key.
Limits
| Item | Limit |
|---|---|
| Request body | 16,384 bytes, or the lower limit given per route |
| Response body | 32,768 bytes |
| Path | 256 bytes |
| Request headers | 16 headers, 1,024 bytes in total, 512 bytes per value |
| Time to receive a request body | 5 seconds (408 request_timeout) |
| Open HTTP connections | 5, of which at most 2 WebSocket clients |
| Queued assign, unassign, spool confirmation, tag write or tag clear | Fails if it waits more than 15 seconds before it starts |