Appearance
Configuration API (/api/v1)
Last updated
A small, versioned API for provisioning and auditing appliances from a script — standing up unit #37 without clicking through devices, tags, alarms and dashboards by hand, or checking what fifty units actually have configured.
/api/… | /api/v1/… (this API) | |
|---|---|---|
| Audience | the app's own UI, scripts, custom pages | external scripts, CI, fleet tooling |
| Credential | user sign-in (JWT) | API token |
| Stability | changes with the UI | contract — additive only; breaking changes go to /api/v2 |
| Surface | ~550 endpoints | 13 endpoints |
| Rate limit | none | 120 requests per minute per token |
"Additive only" means new optional fields and new endpoints may appear in v1, but nothing a caller already uses will be removed, renamed, tightened or change status code.
Endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /api/v1/config/schema | config:read | Entity types, their row counts, items and dependency graph |
| POST | /api/v1/config/export | config:read | Build a configuration bundle |
| POST | /api/v1/config/preview | config:read | Dry-run an import: what is new, what conflicts, what is missing |
| POST | /api/v1/config/import | config:write | Apply a bundle |
| GET | /api/v1/devices | config:read | List devices |
| GET | /api/v1/devices/:id | config:read | One device |
| GET | /api/v1/devices/:id/status | config:read | Runtime connection status of one device |
| GET | /api/v1/tags | config:read | Tag subscriptions with their scaling (scale_mode, ranges, scale_clamp, eng_units, and scale_points for lookup tables); ?deviceId= to filter |
| GET | /api/v1/tag-udts | config:read | Tag UDTs |
| GET | /api/v1/local-tags | config:read | Local tags |
| GET | /api/v1/expression-tags | config:read | Expression tags |
| GET | /api/v1/alarm-rules | config:read | Alarm rules |
| GET | /api/v1/dashboards | config:read | Dashboards the token's creator may open (summary, not layout) |
GET /api/v1/openapi.json returns the OpenAPI 3 document for all of the above and needs no token — it describes the contract, not your data. It is generated from the same schemas that validate requests, so it cannot describe something the API rejects. Feed it to a client generator or Postman.
Authentication
Create a token in System → API Tokens (see API Tokens) and send it as a header:
bash
export TOKEN=sk_scadaink_…
curl -s -H "Authorization: Bearer $TOKEN" https://scada.local/api/v1/devicesA user JWT is not accepted here, and a token is not accepted in the query string.
A token acts as the user who created it, as that user is now: it never sees more than they could in the app. A token created by an admin who is still an admin sees everything; see Whose access a token has for the rest.
Responses
Every response uses the envelope { "success": true, "data": … } or { "success": false, "error": "…" }. Validation failures are 400 with the first problem as field: message, for example entityTypes: Array must contain at least 1 element(s).
Listings all have the same shape and return every row (there is no paging):
json
{ "success": true, "data": { "items": [
{ "id": 3, "name": "Press 4", "driver": "modbus", "ip_address": "10.20.1.14", "port": 502, "enabled": 1, "write_enabled": 0 }
], "count": 1 } }| Listing | Fields per item | Order |
|---|---|---|
/devices, /devices/:id | id, name, driver, ip_address, port, enabled, write_enabled | name |
/tags | id, device_id, tag_path, name, data_type, enabled | device, then tag path |
/tag-udts | id, name, description | name |
/local-tags | id, name, data_type, value | name |
/expression-tags | id, name, expression, data_type | name |
/alarm-rules | id, name, tag_subscription_id, condition, threshold, severity, enabled | name |
/dashboards | id, name, slug, folder, description, updated_at | name |
Booleans are 0/1 integers and field names are snake_case, as stored. /devices/:id answers 404 Device not found for an unknown id; /tags?deviceId=abc answers 400 deviceId must be an integer.
/devices/:id/status is the only runtime value in the API — after an import a script needs to know whether the device actually connected:
json
{ "success": true, "data": { "deviceId": 3, "connectionStatus": "connected", "lastSeen": "2026-09-15T06:02:10.998Z" } }A device the engine has never seen reports "connectionStatus": "disconnected", "lastSeen": "". Other fields may be present depending on the driver.
Provision an appliance: the full flow
- On the reference appliance, create a
config:readtoken and export a bundle. - On the target appliance, create a token with
config:readandconfig:write. - Preview the bundle on the target and read the summary and warnings.
- Import with the
bundleHashfrom the preview asexpectedHash. - Re-enter credentials that were stripped from the export (device passwords and other driver credentials, Sparkplug broker passwords and certificates).
- Verify with the listings and
/devices/:id/status.
bash
REF=https://press-line-ref.local
NEW=https://press-line-37.local
# 1. Export devices, tags, alarm rules and dashboards (dependencies are added automatically)
curl -s -X POST $REF/api/v1/config/export \
-H "Authorization: Bearer $REF_TOKEN" -H "Content-Type: application/json" \
-d '{"entityTypes":["tag_subscriptions","alarm_rules","dashboards"]}' \
| jq .data > bundle.json
# 3. Preview on the new unit
jq -n --slurpfile b bundle.json '{bundle:$b[0], conflictMode:"skip"}' > req.json
curl -s -X POST $NEW/api/v1/config/preview \
-H "Authorization: Bearer $NEW_TOKEN" -H "Content-Type: application/json" \
-d @req.json | tee preview.json | jq '.data | {valid, summary, warnings, bundleHash}'
# 4. Import, pinned to exactly what was previewed
HASH=$(jq -r .data.bundleHash preview.json)
jq --arg h "$HASH" '. + {expectedHash:$h}' req.json > import.json
curl -s -X POST $NEW/api/v1/config/import \
-H "Authorization: Bearer $NEW_TOKEN" -H "Content-Type: application/json" \
-d @import.json | jq .data.importedNote that the bundle is the data of the export response, not the whole envelope.
Export request
| Field | Type | Default | Meaning |
|---|---|---|---|
entityTypes | string array | required, at least one | Types to include — the keys from /config/schema. Dependencies are added for you: asking for alarm_rules also exports tag_subscriptions and devices. |
excludeSensitive | boolean | true | Blank credential columns. Leave it on unless the bundle travels only over a channel you trust. |
entityIds | object | all rows | Per-type id filter, for example { "dashboards": [4, 9] }. Types not named are exported in full. |
Preview and import requests
| Field | Type | Default | Meaning |
|---|---|---|---|
bundle | object | required | The bundle, exactly as returned in data by export |
conflictMode | skip or overwrite | skip | What to do when an entity already exists (below) |
entityTypes | string array | everything in the bundle | Apply only these types from the bundle |
expectedHash | string | none | Import only: the bundleHash from preview. If the bundle differs, the import is refused. |
conflictMode. skip leaves an existing entity untouched and counts it as skipped. overwrite replaces the existing entity's fields with the bundle's. Any other value — a typo, the wrong case — is refused (400) rather than guessed at. An entity "already exists" when its identity matches, for example a device with the same name or a dashboard with the same slug; see Bundles and Entity Types for each type's identity.
Preview response
json
{ "success": true, "data": {
"valid": true, "version": 1,
"exportedAt": "2026-09-15T05:40:02.117Z", "sourceInstance": "Press Line Reference",
"entityTypes": ["devices", "tag_subscriptions", "alarm_rules", "dashboards"],
"summary": {
"devices": { "total": 4, "new": 3, "conflict": 1, "conflictNames": ["Press 4"] },
"tag_subscriptions": { "total": 212, "new": 212, "conflict": 0, "conflictNames": [] }
},
"warnings": ["1 existing entities will be skipped (conflict mode: skip)"],
"missingDependencies": [],
"bundleHash": "9f2c…"
} }Preview never fails on a bad bundle: it answers success: true with "valid": false and the reason in warnings (for example Not a Scada.Ink export file). Always check valid. conflictNames lists at most five names per type. missingDependencies lists types the selected types need but the import will not include; rows that reference them are skipped.
Import response
json
{ "success": true, "data": { "imported": {
"devices": { "inserted": 3, "skipped": 1, "overwritten": 0, "failed": 0 },
"tag_subscriptions": { "inserted": 212, "skipped": 0, "overwritten": 0, "failed": 0 },
"alarm_rules": { "inserted": 18, "skipped": 0, "overwritten": 0, "failed": 1,
"errors": ["alarm_rules: ignored unknown column(s) \"escalation\""] }
} } }- The import runs in one database transaction. A database error on any row (for example a constraint violation) aborts it, rolls everything back, and the response is
400with the message. skippedcounts both conflicts inskipmode and rows whose parent is missing (a tag whose device is neither in the bundle nor on the target).failedcounts rows with nothing importable. Up to ten messages per type appear inerrors, including columns that were ignored. Check every type's counts, not justsuccess.- Ids are remapped: a tag that referenced device 12 on the reference appliance points at whatever id that device received on the target, including ids inside JSON settings such as dashboard widget bindings.
- Restart after importing. The import writes the database but does not tell the running engines. Enabled devices with auto-reconnect on are picked up by the reconnect check within about 10 seconds, but imported or overwritten alarm rules, workflows, scripts and schedules only take effect after the API service restarts (or the appliance reboots), or after each one is opened and saved. Then use
/devices/:id/statusto confirm.
Rate limit
120 requests per minute per token, counted in a one-minute window that starts with the token's first request. It applies to /api/v1 only. Over the limit you get 429 with a Retry-After header in seconds and Rate limit exceeded: 120 requests per minute per token. Retry in <n>s. A real provisioning run makes a handful of requests; approaching the limit usually means a loop over individual resources that should be one bundle call.
What v1 does not do
- Change a single resource. Adding one device to a running appliance is not available here yet; export a bundle with
entityIdsand import it, or use the UI. - Users and credentials. No endpoint creates users or tokens — a leaked CI secret must not be able to mint an administrator.
- Runtime actions — writing tag values, acknowledging alarms, connecting devices. Use the REST API.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 A valid API token is required… | No/revoked/expired token, a user JWT, or ?token= | See API Tokens |
403 This token does not have the "config:read" scope. | Token created with only config:write, used for something other than import | Create a token with both scopes |
400 conflictMode: Invalid enum value. Expected 'skip' | 'overwrite'… | conflictMode is not exactly skip or overwrite | Use lower-case skip or overwrite |
400 bundle: bundle must be an object | Sent the file as a string, or the whole export envelope nested wrongly | Send {"bundle": <object from export's data>} |
Preview "valid": false, Not a Scada.Ink export file | Sent the export envelope ({success, data}) instead of data, or not a bundle | Pass .data of the export |
Export file version N is not supported by this instance | Bundle from a newer software version | Update the target appliance first |
400 This file does not match the one that was previewed. Preview it again before importing. | expectedHash does not match — the bundle changed, or the JSON was re-serialized with different key order | Preview and import the same bytes; take the hash from the same preview |
500 request entity too large | Request body over 100 KB — the API's JSON limit applies to import and preview, and bundles with dashboards, scripts or hundreds of tags easily exceed it | Split the bundle by entityTypes and entityIds into smaller imports (devices and tags first), or use Selective Import on the Backup page, which uploads the file instead |
429 Rate limit exceeded… | More than 120 calls in a minute on one token | Wait Retry-After seconds; use bundle calls instead of loops |
| Devices imported but never connect | excludeSensitive blanked their driver settings | Re-enter the driver settings on the target (see Bundles) |