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
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:
| Parameter | Type | Description |
|---|---|---|
lookup | string | Warm query lookup key or API path |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
timeRange | string | ISO 8601 duration (e.g. PT1H) |
limit | number | Max rows to return |
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:
| Parameter | Type | Description |
|---|---|---|
lookup | string | Data 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:
| Parameter | Type | Description |
|---|---|---|
connection | string | Filter by data connection lookup |
surface | string | Filter 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.
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.
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.
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:
| Parameter | Type | Description |
|---|---|---|
scopeKey | string | The scope to read. Omit for workspace-level Platform-Config records |
target | string | Container: PlatformConfig, AccessControl, DomainEvents, DomainAmendments |
entityType | string | Filter by entity type |
actionType | string | Filter by action type |
startDate | string | Start date (ISO 8601) |
endDate | string | End date (ISO 8601) |
userId | string | Filter by user |
fromSequence | number | Start of a sequence window (integer) |
toSequence | number | End of a sequence window (integer) |
limit | number | Max results (default: 100) |
offset | number | Pagination 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:
| Header | Description |
|---|---|
x-provenance-scanned | Records actually read to answer this query — the cost signal |
x-provenance-has-more | true when the result is a prefix, not a complete history |
x-provenance-target | Which container answered |
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.
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:
| Field | Type | Description |
|---|---|---|
Valid | boolean | null | This 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 |
ChainValid | boolean | The whole container's chain |
Empty | boolean | Whether there was anything to check at all |
Record | object | null | The named record; null on a whole-chain verify |
RecomputedHash | string | null | The independently recomputed digest; null on a whole-chain verify |
BrokenAt | string | null | The first bad link — a record id, a blob name, or a hash on a fork |
ForkDetected | boolean | Two records claim the same predecessor |
Reason | string | null | The verdict in words; disambiguates BrokenAt |
Target | string | Which 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.
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.
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:
| Parameter | Type | Description |
|---|---|---|
format | string | csv or json (default: json) |
scopeKey | string | The scope to export. Omit for workspace-level Platform-Config records |
target | string | Container to export from |
entityType | string | Filter by entity type |
actionType | string | Filter by action type |
userId | string | Filter by user |
startDate | string | Start date (ISO 8601) |
endDate | string | End date (ISO 8601) |
limit | number | Max 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
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.
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.
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.
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:
| Parameter | Type | Description |
|---|---|---|
scopeKey | string | Required. The scope whose corrections to read |
entityId | string | Filter by entity ID |
entityType | string | Filter by entity type |
reasonCategory | string | Filter by reason category |
amendedBy | string | Filter by user who amended |
status | string | pending, approved, or rejected |
startDate | string | Start date (ISO 8601), compared against AmendedAt |
endDate | string | End date (ISO 8601), compared against AmendedAt |
fromSequence | number | Start of a sequence window (integer) |
toSequence | number | End of a sequence window (integer) |
limit | number | Max results (default: 100) |
offset | number | Pagination 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.
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.
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"
}
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.
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:
| Status | When |
|---|---|
200 | Approved or rejected — body is the new transition record |
400 | Missing scopeKey, missing entityId on the chain endpoint, failed validation, or an off-list reason category |
401 | No authenticated identity on the session |
403 | Maker/checker — the approver or rejector is the amendment's own author |
404 | No amendment with that id in this scope |
409 | The amendment is not pending; the body names its current status |
503 | Provenance 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:
| Parameter | Type | Description |
|---|---|---|
lookup | string | Database 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.
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:
| Parameter | Type | Description |
|---|---|---|
store | string | Lookup key of the data store that owns the database |
database | string | Database lookup key |
table | string | Container name |
documentId | string | Cosmos item id, for single-document calls |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
partitionKey | string | Required 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:
| Status | Code | Meaning |
|---|---|---|
400 | PARTITION_KEY_REQUIRED | Single-document call without the partition key |
404 | DATABASE_NOT_FOUND | No such database in this workspace |
404 | DATABASE_NOT_IN_STORE | That database is not owned by the store in the path |
404 | TABLE_NOT_FOUND | No such table in that database |
404 | TABLE_NOT_EXPOSED | The container publishes nothing |
403 | OPERATION_NOT_EXPOSED | The container does not switch this operation on |
403 | DATA_PLANE_FORBIDDEN | The account exists but document access was not granted |
429 | THROTTLED | The 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.
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
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:
| Parameter | Type | Description |
|---|---|---|
minutes | number | Token expiration in minutes (default: 60) |
scopes | string | Comma-separated access rights to include |
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:
| Status | Description | Resolution |
|---|---|---|
| 401 | Unauthorized — invalid or missing JWT | Check token, regenerate if expired |
| 403 | Forbidden — insufficient access rights | Verify your access rights |
| 404 | Resource not found | Check lookup key or path |
| 400 | Invalid request | Check request body or parameters |
| 500 | Server error | Retry or contact support |
Related
- API Explorer Guide — Interactive browsing and testing
- MCP Integration — Connect AI assistants
- Governance — Audit and amendment log guides
- Troubleshooting — Common API issues
On this page
- FrontmatterVersion: 1 DocumentType: Reference Title: "REST API" Summary: "Every consumer endpoint under /oi-api/: warm queries, cold data, live stream, activity log, corrections, and the MCP server, plus error codes." Created: 2026-01-19
- REST API Reference
- ╰─▶Overview
- ╰─▶Warm Queries
- ╰─▶Cold Data
- ╰─▶Live Stream
- ╰─▶Activity Log
- ╰─▶Corrections
- ╰─▶Data Stores
- ╰─▶MCP Server
- ╰─▶Authentication
- ╰─▶Error Handling
- ╰─▶Related