REST API Reference

Complete reference for the OpenIndustrial REST API. Everything you need to integrate programmatically.


Overview

All consumer API endpoints live under the /oi-api/ path on your workspace host.

Base URL:

https://{your-workspace-host}/oi-api/

Authentication:

Authorization: Bearer YOUR_JWT_TOKEN

Generate a JWT from the API Keys menu in your workspace, or use the authentication panel in the API Explorer (workspace menu → APIs → API Explorer).

OpenAPI Spec:

A governance-filtered OpenAPI 3.1 specification is available at:

GET /oi-api/openapi
Thinking Tip:

The API Explorer provides interactive, governance-filtered documentation with a Try-Me playground. Open it from your workspace menu → APIs → API Explorer.


Warm Queries

Execute Named Query

GET /oi-api/warm-queries/{lookup}

Execute a pre-configured KQL warm query by its lookup key or API path. Returns time-series results from Azure Data Explorer.

Path Parameters:

ParameterTypeDescription
lookupstringWarm query lookup key or API path

Query Parameters:

ParameterTypeDescription
timeRangestringISO 8601 duration (e.g. PT1H)
limitnumberMax rows to return
Thinking Tip:

Your configured warm queries appear in the API Explorer under the Warm Queries tab. Each shows its lookup path and a Try-Me form.

Execute Ad-hoc Query

POST /oi-api/warm-queries

Execute a user-provided KQL query against the workspace data cluster.

Request Body:

{
  "query": "TelemetryTable | where timestamp > ago(1h) | summarize avg(temperature) by bin(timestamp, 5m)",
  "timeRange": "PT1H",
  "limit": 100
}

Cold Data

Download by Data Connection

POST /oi-api/downloads/data-connections/{lookup}

Initiate a cold data download for the specified data connection.

Path Parameters:

ParameterTypeDescription
lookupstringData connection lookup key

Request Body:

{
  "format": "csv",
  "dateRange": {
    "start": "2026-01-01T00:00:00Z",
    "end": "2026-01-31T23:59:59Z"
  }
}

Download by Surface

POST /oi-api/downloads/surfaces/{surfaceLookup}

Download cold data for all connections within a surface.

Download by Surface and Connection

POST /oi-api/downloads/surfaces/{surfaceLookup}/connection/{connLookup}

Download cold data scoped to a single connection within a surface.


Live Stream

GET /oi-api/live-stream

Subscribe to real-time device telemetry via WebSocket. Events are JSON-encoded telemetry payloads.

Query Parameters:

ParameterTypeDescription
connectionstringFilter by data connection lookup
surfacestringFilter by surface lookup

Activity Log

Scope is a filter, not part of the path. Reads pass scopeKey as a query parameter; writes carry ScopeKey in the body. Leave it off an activity-log read and you get the workspace-level Platform-Config records — the platform's own change history, which the earlier per-log URL shape had no way to address.

target selects the container explicitly and is always a query parameter, on reads and writes alike — PlatformConfig, AccessControl, DomainEvents or DomainAmendments. Omit it and the route infers one: a request with a scopeKey reads that scope's domain container, and one without reads PlatformConfig. Combining a workspace target with a scopeKey, or naming a domain target without one, is a 400 rather than a guess.

Write Audit Event

POST /oi-api/provenance/events

Record a new audit event. Scope is optional here — omit it and the event lands in the workspace-level Platform-Config container.

Request Body:

{
  "ScopeKey": "STUDY-001",
  "ActionType": "Update",
  "EntityType": "Connection",
  "EntityID": "production-sensors",
  "BeforeState": { "name": "prod-sensors", "status": "active" },
  "AfterState": { "name": "production-sensors", "status": "active" }
}

ActionType, EntityType and EntityID are required. NodeID, BeforeState, AfterState, Name and Description are optional.

Thinking Tip:

Fields you send that the platform owns are dropped, not refused. UserID and Timestamp are overwritten with the authenticated principal and the server clock. RecordID, PreviousHash and Hash are computed during the append. So is any field not listed above — there is no metadata passthrough on an audit event. A request carrying "Hash": "forged" returns 201, having discarded it. Do not read a success as confirmation that everything you sent was stored.

Responses: 201 with the stored record, including its assigned RecordID (a 12-digit zero-padded sequence), PreviousHash and Hash. 401 when the session carries no identity — an event must be attributable. 400 when a required field is missing. 503 when Provenance is not provisioned for the workspace.

Thinking Tip:

There is deliberately no vocabulary check on ActionType or EntityType. Any non-empty string is accepted and stored verbatim, so a vertical can use its own verbs without registering them. The values offered in the product are suggestions, not a closed list.

Thinking Tip:

The first write to a brand-new scope provisions its storage synchronously, which takes seconds to tens of seconds and can time out and then succeed on an immediate retry. Retry once before treating it as a failure; the append is not duplicated.

Query Audit Events

GET /oi-api/provenance/events/query

Query Parameters:

ParameterTypeDescription
scopeKeystringThe scope to read. Omit for workspace-level Platform-Config records
targetstringContainer: PlatformConfig, AccessControl, DomainEvents, DomainAmendments
entityTypestringFilter by entity type
actionTypestringFilter by action type
startDatestringStart date (ISO 8601)
endDatestringEnd date (ISO 8601)
userIdstringFilter by user
fromSequencenumberStart of a sequence window (integer)
toSequencenumberEnd of a sequence window (integer)
limitnumberMax results (default: 100)
offsetnumberPagination offset (default: 0)

Returns a JSON array of events, newest first. A scope that has never been written to returns 200 with an empty array, not a 404.

Response Headers:

HeaderDescription
x-provenance-scannedRecords actually read to answer this query — the cost signal
x-provenance-has-moretrue when the result is a prefix, not a complete history
x-provenance-targetWhich container answered
Thinking Tip:

Prefer fromSequence / toSequence over offset for walking a log. The scan runs newest-first, so an offset is a position in a list that grows at the end you started from — page through a live log with it and records shift under you. A sequence window addresses records by identity instead and is stable. These two are also the only parameters validated: a non-integer is a 400.

Verify Hash Chain

POST /oi-api/provenance/events/verify

Request Body:

{
  "ScopeKey": "STUDY-001",
  "RecordID": "000000000042"
}

Both fields are optional, and omitting RecordID asks a different question. Leave it out and you get a whole-chain verification, genesis to tip — which is what a "verify this log" button wants, and what you need when you hold no record id. Supply one and you additionally get that record's own digest recomputed. A supplied id must be the 12-digit zero-padded sequence the platform assigned; a malformed one is rejected rather than treated as "verify everything", because a typo must never come back as a clean bill of health.

Thinking Tip:

The distinction is presence of the key, not whether its value is truthy. Omit RecordID entirely for a whole-chain verify. Sending "", null or 0 is a 400 — those look like a variable that did not get filled in, and guessing at them is how a failed lookup turns into a passing audit.

Response Fields:

FieldTypeDescription
Validboolean | nullThis record's digest, recomputed and compared. null when you named no record — nothing was recomputed, and claiming true would report a check that never ran
ChainValidbooleanThe whole container's chain
EmptybooleanWhether there was anything to check at all
Recordobject | nullThe named record; null on a whole-chain verify
RecomputedHashstring | nullThe independently recomputed digest; null on a whole-chain verify
BrokenAtstring | nullThe first bad link — a record id, a blob name, or a hash on a fork
ForkDetectedbooleanTwo records claim the same predecessor
Reasonstring | nullThe verdict in words; disambiguates BrokenAt
TargetstringWhich container was verified

Valid and ChainValid are independent, and the combination matters. A valid record on a broken chain — Valid: true with ChainValid: false — is a real state, and usually the one an auditor cares about most.

Thinking Tip:

ChainValid: true with Empty: true means nothing was checked, not that everything checks out. The container is unwritten or does not exist — and a mistyped scopeKey is indistinguishable from an empty scope. Check Empty before rendering a green tick.

Thinking Tip:

A whole-chain verify reads every record in the container and recomputes every digest. It is authoritative, and it is not cheap — a deliberate action, not something to poll. Unlike the query and export routes, this one returns which container answered as a Target field in the body rather than as a header.

Export Audit Events

GET /oi-api/provenance/events/export

Export audit events in CSV or JSON format, with the fields a 21 CFR Part 11 review asks for.

Query Parameters:

ParameterTypeDescription
formatstringcsv or json (default: json)
scopeKeystringThe scope to export. Omit for workspace-level Platform-Config records
targetstringContainer to export from
entityTypestringFilter by entity type
actionTypestringFilter by action type
userIdstringFilter by user
startDatestringStart date (ISO 8601)
endDatestringEnd date (ISO 8601)
limitnumberMax records (default: 10000)

Both formats download as an attachment, named provenance-<scope>.csv — or provenance-<target>.csv when you exported a workspace container. The JSON form carries whole records; the CSV form carries the columns below.

CSV columns, in order:

RecordID,Timestamp,UserID,ActionType,EntityType,EntityID,PreviousHash,Hash
Thinking Tip:

Signature is gone — read Hash instead. Alongside it, EventID became RecordID and PreviousEventHash became PreviousHash, when the store moved to immutable blob storage. Code reading Signature off an export gets undefined rather than an error, so this one fails quietly. BeforeState and AfterState are not CSV columns — export as JSON if you need them.

Thinking Tip:

Export has no pagination — no sequence window and no offset, just limit. Check x-provenance-has-more on the response: if it is true, you exported a prefix rather than the whole log, which is not what an evidentiary export is for. Narrow the date range or the scope instead of paging.


Corrections

scopeKey is required on every corrections endpoint. A correction always describes a change to some scope's data, so there is no workspace-level container to fall back to — omitting it is a 400, not a default. Reads take it as a query parameter; creates carry ScopeKey in the body.

Create Amendment

POST /oi-api/provenance/amendments

Create a documented correction with reason-for-change and original-record preservation. The server assigns the record id, the timestamp, the chain linkage and the status.

Request Body:

{
  "ScopeKey": "STUDY-001",
  "EntityType": "sample",
  "EntityID": "sample-456",
  "OriginalRecord": { "weight": 10.5 },
  "AmendedRecord": { "weight": 10.7 },
  "ChangedFields": ["weight"],
  "ReasonCategory": "data-entry-error",
  "ReasonText": "Scale reading was misread during morning calibration"
}

Every field above is required, including ReasonText — a correction with no stated reason is refused. ChangedFields must name at least one field.

Thinking Tip:

Do not send AmendedBy. The author is the authenticated session principal; a supplied value is discarded. Same for AmendedAt and Status, which the server sets. Status is pending when RequireApproval or RequireTwoPersonAuth is enabled on the workspace Provenance policy — not on a node — and approved otherwise.

Responses: 201 with the stored amendment and its RecordID. 401 when the session carries no identity — the identity check runs before validation, so an unauthenticated caller learns nothing about whether the body was well-formed. 400 for a missing field, for a ReasonCategory outside the workspace's configured list (the response names what you sent and what is allowed), or for empty ReasonText when the workspace requires free text on every category. 503 when Provenance is not provisioned.

Thinking Tip:

Reason categories are enforced here, unlike ActionType on an audit event. The asymmetry is deliberate: a category is a curated compliance taxonomy a sponsor's SOP defines, while an action verb is whatever a vertical's code emits. Fetch the live list from /oi-api/provenance/reason-categories rather than hard-coding it — it is the same source this route validates against.

Query Amendments

GET /oi-api/provenance/amendments

Query Parameters:

ParameterTypeDescription
scopeKeystringRequired. The scope whose corrections to read
entityIdstringFilter by entity ID
entityTypestringFilter by entity type
reasonCategorystringFilter by reason category
amendedBystringFilter by user who amended
statusstringpending, approved, or rejected
startDatestringStart date (ISO 8601), compared against AmendedAt
endDatestringEnd date (ISO 8601), compared against AmendedAt
fromSequencenumberStart of a sequence window (integer)
toSequencenumberEnd of a sequence window (integer)
limitnumberMax results (default: 100)
offsetnumberPagination offset (default: 0)

Returns a JSON array, newest first, with the same x-provenance-scanned, x-provenance-has-more and x-provenance-target headers as the events query.

Thinking Tip:

The list lives on the collection itself — there is no /query sub-resource here, which is the one place the corrections routes differ in shape from the activity-log ones. Filters on a GET of the collection is the shape a reader expects; the events tree keeps /events/query because a bare POST /events is the append.

Get Amendment Chain

GET /oi-api/provenance/amendments/chain?scopeKey={scope}&entityId={id}

Returns the chain of corrections for a specific entity. Both scopeKey and entityId are required; a missing entityId is a 400. An entity with no corrections returns an empty array rather than a 404.

Thinking Tip:

Ordered oldest to newest, by chain link — not by timestamp. The order comes from the hash linkage the platform assigned, so it is the order the corrections actually happened in, and it cannot be shifted by a client-supplied AmendedAt. A chain is capped at 1000 entries: if x-provenance-has-more comes back true, you are holding a prefix, and must not present it as the complete history of that record.

Get Reason Categories

GET /oi-api/provenance/reason-categories

Returns the workspace's reason categories. Workspace-wide policy — it takes no scope, and it is the same source the create endpoint validates against, so a dropdown built from it cannot disagree with the validator. Falls back to the 7 standard GxP categories when not customised; a configured list replaces the defaults rather than extending them.

{
  "categories": [
    "data-entry-error",
    "transcription-error",
    "equipment-malfunction",
    "protocol-deviation",
    "recalculation",
    "delayed-entry",
    "other"
  ]
}

Approve Amendment

POST /oi-api/provenance/amendments/{id}/approve?scopeKey={scope}

Second-signer approval for maker/checker separation (EU Annex 11). The approver is the authenticated session principal and is enforced server-side to differ from the amendment's author — there is no ApprovedBy to send, and one in a body is never read. scopeKey is required and must be on the query string for this endpoint.

Reject Amendment

POST /oi-api/provenance/amendments/{id}/reject

Reject a correction. The rejector is the authenticated session principal, enforced to differ from the author. scopeKey is required and may travel either in the body or on the query string.

Request Body:

{
  "ScopeKey": "STUDY-001",
  "RejectionReason": "Correction value exceeds validated range — needs QA review"
}
Thinking Tip:

Always send RejectionReason. A rejection with no stated reason is not a usable compliance record — but the platform does not currently refuse one, and an omitted reason is simply absent from the stored record rather than reported back to you. Treat it as required on your side. Do not send RejectedBy; it is the session principal and a supplied value is ignored.

Thinking Tip:

Both approve and reject APPEND a transition record — they do not patch the original. Under a locked immutability policy the pending record cannot be rewritten, so the response carries a new RecordID and the original stays in the chain. That is what makes the decision itself part of the evidence. Both return 200; the two create endpoints return 201.

Amendment Responses:

StatusWhen
200Approved or rejected — body is the new transition record
400Missing scopeKey, missing entityId on the chain endpoint, failed validation, or an off-list reason category
401No authenticated identity on the session
403Maker/checker — the approver or rejector is the amendment's own author
404No amendment with that id in this scope
409The amendment is not pending; the body names its current status
503Provenance is not provisioned for this workspace

Errors return a JSON body with an error message. There are no stable machine-readable error codes on these endpoints — branch on the HTTP status. | 404 | AMENDMENT_NOT_FOUND | Amendment ID not found | | 409 | AMENDMENT_NOT_PENDING | Amendment already approved or rejected | | 403 | MAKER_CHECKER_VIOLATION | Approver/rejector is the same as submitter |


Data Stores

Endpoints for CosmosDB accounts your workspace provisions and owns. Account credentials are resolved server-side; there is no key to supply on any call here.

List Containers

GET /oi-api/data-stores/{lookup}/containers

List the containers in a database. The listing is read from Azure, so a container created outside the workspace still appears - with Declared: false to mark that the workspace has no record of it.

Path Parameters:

ParameterTypeDescription
lookupstringDatabase lookup key

Create Container

POST /oi-api/data-stores/{lookup}/containers

Request Body:

{
  "name": "sensor-readings",
  "partitionKeyPath": "/deviceId",
  "dataClass": "operational"
}

dataClass is required and has no default. Accepted values are operational, reference and evidence - and evidence is refused, with the reason returned. See Database Management.

Thinking Tip:

partitionKeyPath cannot be changed after the container exists.

Delete Container

DELETE /oi-api/data-stores/{lookup}/containers/{containerName}

Removes the workspace record. For a container that was actually provisioned this refuses rather than reporting a delete it did not perform - delete it in the Azure portal first.


Table Documents

Document endpoints are addressed by the store that owns the database, then the database, then the table. Exposure is decided on the container's own record, so no surface appears in the path.

GET    /oi-api/data-stores/{store}/databases/{database}/tables/{table}
POST   /oi-api/data-stores/{store}/databases/{database}/tables/{table}
GET    /oi-api/data-stores/{store}/databases/{database}/tables/{table}/{documentId}
PUT    /oi-api/data-stores/{store}/databases/{database}/tables/{table}/{documentId}
DELETE /oi-api/data-stores/{store}/databases/{database}/tables/{table}/{documentId}

The table path is the document collection; there is no /documents segment.

Path Parameters:

ParameterTypeDescription
storestringLookup key of the data store that owns the database
databasestringDatabase lookup key
tablestringContainer name
documentIdstringCosmos item id, for single-document calls

Query Parameters:

ParameterTypeDescription
partitionKeystringRequired on single-document calls unless the table is partitioned on /id. On PUT it may instead be read from the document body at the table's partition path

Example:

curl -X DELETE   "https://{your-workspace-host}/oi-api/data-stores/ops-store/databases/ops/tables/readings/rec-8842?partitionKey=device-17"   -H "Authorization: Bearer YOUR_JWT_TOKEN"

Errors specific to these endpoints:

StatusCodeMeaning
400PARTITION_KEY_REQUIREDSingle-document call without the partition key
404DATABASE_NOT_FOUNDNo such database in this workspace
404DATABASE_NOT_IN_STOREThat database is not owned by the store in the path
404TABLE_NOT_FOUNDNo such table in that database
404TABLE_NOT_EXPOSEDThe container publishes nothing
403OPERATION_NOT_EXPOSEDThe container does not switch this operation on
403DATA_PLANE_FORBIDDENThe account exists but document access was not granted
429THROTTLEDThe account is rate-limiting this workspace

Refusals are checked in that order — database, then store ownership, then table, then whether the container is exposed at all, then the verb. A database reached through the wrong store answers 404, not 403, because the container's API settings were never consulted.

Thinking Tip:

An operation turned off for a table is absent from /oi-api/openapi and from the API Explorer. A direct call to a path a client already knew returns 403.

MCP Server

POST /oi-api/mcp

Model Context Protocol server for AI assistant integration. Uses Streamable HTTP transport.

Claude Desktop / Cursor configuration:

{
  "mcpServers": {
    "open-industrial": {
      "url": "https://{your-workspace-host}/oi-api/mcp",
      "headers": { "Authorization": "Bearer YOUR_JWT_TOKEN" }
    }
  }
}

Quick setup with fai CLI:

fai mcp install https://{your-workspace-host}/oi-api/mcp --name open-industrial --auth "YOUR_JWT_TOKEN"
fai mcp preview open-industrial
Thinking Tip:

The fai mcp install command registers the server with all your coding agents (Claude Code, Cursor, Cline, and others). The fai mcp preview command launches the MCP Inspector for interactive testing.


Authentication

All /oi-api/ endpoints require a Bearer JWT token in the Authorization header.

Generate a JWT

GET /workspace/api/keys/jwt

Query Parameters:

ParameterTypeDescription
minutesnumberToken expiration in minutes (default: 60)
scopesstringComma-separated access rights to include
Thinking Tip:

When scopes is omitted, the token includes all of your resolved access rights. Provide scopes for least-privilege tokens.


Error Handling

All endpoints return standard HTTP status codes. Common error responses:

StatusDescriptionResolution
401Unauthorized — invalid or missing JWTCheck token, regenerate if expired
403Forbidden — insufficient access rightsVerify your access rights
404Resource not foundCheck lookup key or path
400Invalid requestCheck request body or parameters
500Server errorRetry or contact support

On this page