Skip to content

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)
Audiencethe app's own UI, scripts, custom pagesexternal scripts, CI, fleet tooling
Credentialuser sign-in (JWT)API token
Stabilitychanges with the UIcontract — additive only; breaking changes go to /api/v2
Surface~550 endpoints13 endpoints
Rate limitnone120 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 ​

MethodPathScopePurpose
GET/api/v1/config/schemaconfig:readEntity types, their row counts, items and dependency graph
POST/api/v1/config/exportconfig:readBuild a configuration bundle
POST/api/v1/config/previewconfig:readDry-run an import: what is new, what conflicts, what is missing
POST/api/v1/config/importconfig:writeApply a bundle
GET/api/v1/devicesconfig:readList devices
GET/api/v1/devices/:idconfig:readOne device
GET/api/v1/devices/:id/statusconfig:readRuntime connection status of one device
GET/api/v1/tagsconfig:readTag subscriptions with their scaling (scale_mode, ranges, scale_clamp, eng_units, and scale_points for lookup tables); ?deviceId= to filter
GET/api/v1/tag-udtsconfig:readTag UDTs
GET/api/v1/local-tagsconfig:readLocal tags
GET/api/v1/expression-tagsconfig:readExpression tags
GET/api/v1/alarm-rulesconfig:readAlarm rules
GET/api/v1/dashboardsconfig:readDashboards 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/devices

A 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 } }
ListingFields per itemOrder
/devices, /devices/:idid, name, driver, ip_address, port, enabled, write_enabledname
/tagsid, device_id, tag_path, name, data_type, enableddevice, then tag path
/tag-udtsid, name, descriptionname
/local-tagsid, name, data_type, valuename
/expression-tagsid, name, expression, data_typename
/alarm-rulesid, name, tag_subscription_id, condition, threshold, severity, enabledname
/dashboardsid, name, slug, folder, description, updated_atname

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 ​

  1. On the reference appliance, create a config:read token and export a bundle.
  2. On the target appliance, create a token with config:read and config:write.
  3. Preview the bundle on the target and read the summary and warnings.
  4. Import with the bundleHash from the preview as expectedHash.
  5. Re-enter credentials that were stripped from the export (device passwords and other driver credentials, Sparkplug broker passwords and certificates).
  6. 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.imported

Note that the bundle is the data of the export response, not the whole envelope.

Export request ​

FieldTypeDefaultMeaning
entityTypesstring arrayrequired, at least oneTypes to include — the keys from /config/schema. Dependencies are added for you: asking for alarm_rules also exports tag_subscriptions and devices.
excludeSensitivebooleantrueBlank credential columns. Leave it on unless the bundle travels only over a channel you trust.
entityIdsobjectall rowsPer-type id filter, for example { "dashboards": [4, 9] }. Types not named are exported in full.

Preview and import requests ​

FieldTypeDefaultMeaning
bundleobjectrequiredThe bundle, exactly as returned in data by export
conflictModeskip or overwriteskipWhat to do when an entity already exists (below)
entityTypesstring arrayeverything in the bundleApply only these types from the bundle
expectedHashstringnoneImport 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 400 with the message.
  • skipped counts both conflicts in skip mode and rows whose parent is missing (a tag whose device is neither in the bundle nor on the target). failed counts rows with nothing importable. Up to ten messages per type appear in errors, including columns that were ignored. Check every type's counts, not just success.
  • 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/status to 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 entityIds and 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 ​

SymptomCauseFix
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 importCreate a token with both scopes
400 conflictMode: Invalid enum value. Expected 'skip' | 'overwrite'…conflictMode is not exactly skip or overwriteUse lower-case skip or overwrite
400 bundle: bundle must be an objectSent the file as a string, or the whole export envelope nested wronglySend {"bundle": <object from export's data>}
Preview "valid": false, Not a Scada.Ink export fileSent the export envelope ({success, data}) instead of data, or not a bundlePass .data of the export
Export file version N is not supported by this instanceBundle from a newer software versionUpdate 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 orderPreview and import the same bytes; take the hash from the same preview
500 request entity too largeRequest 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 itSplit 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 tokenWait Retry-After seconds; use bundle calls instead of loops
Devices imported but never connectexcludeSensitive blanked their driver settingsRe-enter the driver settings on the target (see Bundles)

See also ​

Scada.Ink Help Center