One key. One workspace.
Workspace and identity administrators create keys in Developer settings. Each key belongs to one workspace, including a single-person workspace. The full secret is shown once; the service stores only its SHA-256 hash. Keep keys in a server secret manager.
Authorization: Bearer ot_dev_<64 lowercase hex characters>Development keys start with ot_dev_; production keys start with ot_live_. The server enforces the environment. Only the Authorization header is accepted; cookies and X-API-Key are not API credentials.
The issuer must retain active database and live Clerk membership. Effective scopes are capped by the current roles in both systems. Suspensions, removal, expiry and revocation deny new requests. Viewer roles retain permitted read/download scopes.
| Scope | Permission |
|---|---|
| projects:read | List and read allowed projects |
| projects:write | Create projects; requires a workspace-wide key and editor/admin role |
| models:read | List and read revision metadata |
| models:write | Reserve and complete uploads; editor/admin role required |
| models:download | Issue a private original-file download |
Optional project restrictions reduce access further. A restricted key cannot create projects. Rotation means creating a replacement, updating the integration, then explicitly revoking the old key. An upload is bound to its initiating key.
The v1 surface
Prefix every route below with /api/v1. All private responses use Cache-Control: private, no-store.
/projects/{projectId}/models/{modelId}/index-jobs/projects/{projectId}/models/{modelId}/index-jobs/projects/{projectId}/models/{modelId}/index-jobs/{jobId}/projects/{projectId}/models/{modelId}/index-jobs/{jobId}/{action}/projects/{projectId}/models/{modelId}/indexes/{jobId}/elements/projects/{projectId}/models/{modelId}/indexes/{jobId}/elements/{elementId}/properties/projects/{projectId}/models/{modelId}/indexes/{jobId}/metrics/projects/{projectId}/models/{modelId}/views/projects/{projectId}/models/{modelId}/filter-sets/projects/{projectId}/dashboards/projects/{projectId}/dashboards/{dashboardId}/projects/{projectId}/dashboards/projects/{projectId}/models/{modelId}/views/projects/{projectId}/models/{modelId}/views/{viewId}/projects/{projectId}/models/{modelId}/filter-sets/projects/{projectId}/models/{modelId}/filter-sets/{filterSetId}/projects/{projectId}/viewer-sessions/pair/projects/{projectId}/viewer-sessions/{sessionId}/commands/projects/{projectId}/viewer-sessions/{sessionId}/commands/{commandId}/whoami/projects/projects/projects/{projectId}/projects/{projectId}/models/projects/{projectId}/uploads/projects/{projectId}/uploads/{uploadId}/complete/projects/{projectId}/models/{modelId}/projects/{projectId}/models/{modelId}/download/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/jobs/{jobId}/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/jobs/{jobId}/result/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/jobs/{jobId}/assets/{assetId}/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/jobs/{jobId}/cancel/projects/{projectId}/models/{modelId}/adapters/ifc-to-rhino/jobs/{jobId}/retry/projects/{projectId}/adapters/rhino-to-ifc/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/complete/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/result/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/cancel/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/publish/projects/{projectId}/adapters/rhino-to-ifc/{jobId}/publish/completeCollections accept limit (1–100; default 25) and the previous nextCursor as cursor. Results use ascending UUID order and contain items plus nextCursor. Pagination is not a frozen snapshot.
Success responses contain data and requestId. Creation returns 201; replay returns 200. The OpenAPI document defines every request field and response object.
IFC ↔ Rhino 8 · Exchange v1
Hosting prerequisite: both native converters and API routes are integrated, but production conversion is unavailable. Authorized calls return 503 conversion_unavailable until durable workers are connected. Existing project and original-file operations remain available.
Receive needs models:read and models:download; viewers may receive. Export needs models:write and a current editor/admin role; downloading its result also needs both read scopes. Current database and live Clerk membership, issuer, environment and project restrictions are checked again on status, signing and publication.
Receive: POST the model-specific ifc-to-rhino namespace with a stable Idempotency-Key, then poll /jobs/{jobId}. States are queued, running, succeeded, failed and cancelled. Get /result only after success and request each private /assets/{assetId} capability. Explicit /cancel and /retry preserve fenced attempts; retries are limited to three.
Export: POST the project-specific rhino-to-ifc namespace to reserve a job and signed package PUT; transfer the Exchange ZIP and POST /{jobId}/complete. Poll status, then get /result. Explicit /publish reserves a new immutable IFC upload; transfer the verified IFC and POST /publish/complete. A successful export never replaces the source revision. Export jobs also include awaiting_upload and expired; export progress is 0–100 while receive progress is 0–1. Reservation replay returns 202 with data.replayed=true. There is no retry route, and a fresh failed conversion needs a new operation key.
Control JSON is 16 KiB. Original IFC, export ZIP and generated IFC are limited to 50 MiB. Signed adapter transfers expire after 60 seconds; send no workspace key to storage or worker assets. A revoked caller cannot obtain fresh capabilities, while an issued capability can survive until its expiry.
Workers parse/generate IFC outside Vercel; RhinoCommon runs in Rhino. Exchange uses Z-up metres, double-precision placements, reusable blocks and typed canonical metadata. Unknown User Text, Unicode, nested values and nulls survive through an encoded IFC payload. Mesh reception and tessellated Mesh/Brep/Extrusion export are supported; textures, curves/points/SubD and authoring-parametric reconstruction are unsupported. Source quantities remain provenance.
Receive, preview and bake a model → · Export and upload an explicit selection →
From IFC to verified original
Create a workspace-wide key with all five scopes. Set ONTOLOGIC_API_KEY in your server environment and use a licensed IFC file. These examples create a project, initiate upload, transfer bytes, complete verification, inspect metadata and verify the downloaded SHA-256.
TypeScript · Node 22+
Save the example as quickstart.ts, then run:
node --experimental-strip-types quickstart.ts https://your-host.example sample.ifcComplete TypeScript example
// Node 22+: ONTOLOGIC_API_KEY must be set in the calling server environment.
// node --experimental-strip-types examples/api/quickstart.ts https://your-host.example sample.ifc
import { readFile, stat } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
import { pathToFileURL } from "node:url";
type Project = { id: string };
type Model = {
id: string;
original: { status: string };
analysis: { status: string };
};
type Upload = {
uploadId: string;
modelId: string;
upload: {
url: string;
method: string;
headers: Record<string, string>;
} | null;
};
export async function runQuickstart(
base: string,
key: string,
filename: string,
fetcher: typeof fetch = fetch,
) {
// Validate before hashing or creating remote resources. Keep the local file unchanged during this run.
const maxBytes = 150 * 1024 * 1024;
const file = await stat(filename);
if (!file.isFile() || file.size < 1 || file.size > maxBytes)
throw new Error("Choose an IFC file between 1 byte and 150 MiB.");
const original = await readFile(filename);
if (original.length !== file.size)
throw new Error(
"The IFC file changed while reading; retry with a stable file.",
);
const checksum = createHash("sha256").update(original).digest("hex");
const root = base.replace(/\/$/, "") + "/api/v1";
async function api<T>(
path: string,
method = "GET",
body?: unknown,
operationId?: string,
): Promise<T> {
const response = await fetcher(root + path, {
method,
headers: {
Authorization: "Bearer " + key,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(operationId ? { "Idempotency-Key": operationId } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await response.json();
if (!response.ok)
throw new Error(
"API " +
response.status +
": " +
json.error.code +
" (request " +
json.error.requestId +
")",
);
return json.data as T;
}
await api("/whoami");
// Persist these IDs if adding application-level retries. Reuse the same ID and payload on retry.
const project = await api<Project>(
"/projects",
"POST",
{ name: "IFC integration", description: "API upload" },
randomUUID(),
);
const session = await api<Upload>(
"/projects/" + project.id + "/uploads",
"POST",
{
filename: basename(filename),
revision: "A",
bytes: original.length,
checksum,
},
randomUUID(),
);
if (session.upload) {
const transfer = await fetcher(session.upload.url, {
method: session.upload.method,
headers: session.upload.headers,
body: original,
});
if (!transfer.ok)
throw new Error("Storage upload failed with HTTP " + transfer.status);
}
// A 409 verification_in_progress can be retried after Retry-After. Integrity failures need a new revision.
const model = await api<Model>(
"/projects/" + project.id + "/uploads/" + session.uploadId + "/complete",
"POST",
{},
);
await api("/projects/" + project.id + "/models");
const details = await api<Model>(
"/projects/" + project.id + "/models/" + model.id,
);
const download = await api<{ url: string; checksum: string }>(
"/projects/" + project.id + "/models/" + model.id + "/download",
);
const response = await fetcher(download.url);
if (!response.ok) throw new Error("Download failed");
const returned = new Uint8Array(await response.arrayBuffer());
if (
createHash("sha256").update(returned).digest("hex") !== checksum ||
download.checksum !== checksum
)
throw new Error("Downloaded original differs from the uploaded file.");
return {
projectId: project.id,
modelId: model.id,
original: details.original.status,
analysis: details.analysis.status,
checksum,
};
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
const [base, filename] = process.argv.slice(2),
key = process.env.ONTOLOGIC_API_KEY;
if (!base || !filename || !key)
throw new Error("Provide base URL, IFC filename and ONTOLOGIC_API_KEY.");
console.log(await runQuickstart(base, key, filename));
}
curl · PowerShell 7+
Save as quickstart.ps1 and run ./quickstart.ps1 -BaseUrl https://your-host.example -IfcFile sample.ifc.
Complete curl example
# PowerShell 7+ and curl. Set ONTOLOGIC_API_KEY in your server environment.
param([Parameter(Mandatory=$true)][string]$BaseUrl,[Parameter(Mandatory=$true)][string]$IfcFile)
$ErrorActionPreference = 'Stop'
if (-not $env:ONTOLOGIC_API_KEY) { throw 'Set ONTOLOGIC_API_KEY first.' }
$apiExampleFile = (Resolve-Path -LiteralPath $IfcFile).Path
$apiExampleRoot = $BaseUrl.TrimEnd('/') + '/api/v1'
$apiExampleBytes = (Get-Item -LiteralPath $apiExampleFile).Length
if ($apiExampleBytes -lt 1 -or $apiExampleBytes -gt 157286400) { throw 'Choose an IFC file between 1 byte and 150 MiB.' }
# Keep the local file unchanged while hashing/transferring.
$apiExampleHash = (Get-FileHash -LiteralPath $apiExampleFile -Algorithm SHA256).Hash.ToLowerInvariant()
$apiExampleTemp = Join-Path ([System.IO.Path]::GetTempPath()) ('ontologic-api-' + [guid]::NewGuid())
New-Item -ItemType Directory -Path $apiExampleTemp | Out-Null
function Invoke-Ontologic([string]$Path,[string]$Method='GET',$Body=$null,[string]$Idempotency='') {
$apiExampleArgs = @('--silent','--show-error','--fail-with-body','-X',$Method,'--header',('Authorization: Bearer ' + $env:ONTOLOGIC_API_KEY))
if ($null -ne $Body) {
$apiExampleJson = Join-Path $apiExampleTemp 'request.json'
[System.IO.File]::WriteAllText($apiExampleJson,($Body | ConvertTo-Json -Compress))
$apiExampleArgs += @('--header','Content-Type: application/json','--data-binary',('@' + $apiExampleJson))
}
if ($Idempotency) { $apiExampleArgs += @('--header',('Idempotency-Key: ' + $Idempotency)) }
$apiExampleOutput = & curl.exe @apiExampleArgs ($apiExampleRoot + $Path)
if ($LASTEXITCODE -ne 0) { throw ('API request failed: ' + $apiExampleOutput) }
return ($apiExampleOutput | ConvertFrom-Json).data
}
try {
$null = Invoke-Ontologic '/whoami'
# Persist/reuse these IDs for retries of the same operation. Each script run creates a new project.
$apiExampleProject = Invoke-Ontologic '/projects' 'POST' @{name='IFC integration';description='curl upload'} ([guid]::NewGuid().ToString())
$apiExamplePath = '/projects/' + $apiExampleProject.id
$apiExampleUpload = Invoke-Ontologic ($apiExamplePath + '/uploads') 'POST' @{
filename=[System.IO.Path]::GetFileName($apiExampleFile);revision='A';bytes=$apiExampleBytes;checksum=$apiExampleHash
} ([guid]::NewGuid().ToString())
if ($apiExampleUpload.upload) {
$null = & curl.exe --silent --show-error --fail-with-body -X PUT --header 'Content-Type: application/octet-stream' --data-binary ('@' + $apiExampleFile) $apiExampleUpload.upload.url
if ($LASTEXITCODE -ne 0) { throw 'IFC transfer failed.' }
}
$apiExampleModel = Invoke-Ontologic ($apiExamplePath + '/uploads/' + $apiExampleUpload.uploadId + '/complete') 'POST' @{}
$null = Invoke-Ontologic ($apiExamplePath + '/models')
$apiExampleDetails = Invoke-Ontologic ($apiExamplePath + '/models/' + $apiExampleModel.id)
$apiExampleDownload = Invoke-Ontologic ($apiExamplePath + '/models/' + $apiExampleModel.id + '/download')
$apiExampleReturned = Join-Path $apiExampleTemp 'download.ifc'
& curl.exe --silent --show-error --fail-with-body --output $apiExampleReturned $apiExampleDownload.url
if ($LASTEXITCODE -ne 0) { throw 'IFC download failed.' }
if ((Get-FileHash -LiteralPath $apiExampleReturned -Algorithm SHA256).Hash.ToLowerInvariant() -ne $apiExampleHash) { throw 'Original checksum mismatch.' }
@{projectId=$apiExampleProject.id;modelId=$apiExampleModel.id;original=$apiExampleDetails.original.status;analysis=$apiExampleDetails.analysis.status;checksum=$apiExampleHash} | ConvertTo-Json
} finally {
# Remove only the exact three temporary files this example created.
foreach ($apiExampleName in @('request.json','download.ifc')) {
$apiExampleTarget = Join-Path $apiExampleTemp $apiExampleName
if (Test-Path -LiteralPath $apiExampleTarget) { Remove-Item -LiteralPath $apiExampleTarget }
}
Remove-Item -LiteralPath $apiExampleTemp
}
The storage transfer is a PUT to the returned signed URL with Content-Type: application/octet-stream. Send the IFC bytes directly. Do not add the workspace API key to storage requests. Overwrite is disabled.
Verification is a distinct state
Model responses include upload.status and upload.expiresAt. An expired session returns upload.status: expired and failureReason: upload_expired; start a new revision. Active verification includes retryAfterSeconds. After a crashed verification lease expires, the session returns to a retryable pending state.
An initiated revision is pending. Completion reads the stored original and checks byte count, SHA-256, the STEP envelope and an IFC2X3, IFC4 or IFC4X3_ADD2 schema header. The complete HEADER and first DATA marker must fit within the first 16 KiB; comments and quoted descriptions cannot supply the schema. Successful completion returns original.status: verified; integrity failure leaves a failed revision and requires a new upload.
Every revision retains filename, revision label, uploader, checksum, size and schema. Files are immutable, including repeated imports of the same content. Never send trusted parser counts or analysis results to completion.
analysis.status remains unavailable. Geometry and properties are parsed in the viewing browser. Browser-reported parser metadata is labeled as such. Indexed element, metric and saved-resource endpoints belong to the default-off private pilot described above. General ingestion, webhooks and production SDKs remain planned.
Open a verified original in your workspace to inspect its geometry and properties. Parsing runs in your browser; it does not publish a server analysis index.
Bounded work and safe retries
| Limit | Value |
|---|---|
| IFC original | 50 MiB (private workspace) |
| HEADER through first DATA marker | 16 KiB prefix |
| JSON request | 16 KiB |
| API calls | 60/key/minute and 300/workspace/minute |
| Pending upload sessions | 10/workspace |
| New API revisions | 100/workspace/UTC day |
| API reserved bytes | 500 MiB/workspace/UTC day |
| Workspace bytes at API upload initiation | 1 GiB/workspace, including failed/expired revisions |
| Projects | 1,000/workspace |
| Key records | 100/workspace, including revoked keys |
| Concurrent original verification | 2/workspace |
| Verification | 43-second fetch deadline; 90-second fenced lease |
| Upload session | 2 hours from initiation |
| Upload capability | 2 hours from issuance |
| Download capability | 60 seconds |
Send an Idempotency-Key of 8–128 letters, numbers or _ . : - for project creation and upload initiation. Persist it with your request payload. Reservations are bound to key, workspace, route and normalized payload; matching replays return the same resource. A different payload returns 409. Completion is naturally idempotent by upload ID.
A pending initiation replay may issue a fresh storage capability, but never extends the original session deadline. Revocation prevents new API requests and finalization. Previously issued storage tokens can remain usable until expiry; a fresh replay token can outlast its session deadline. Cleanup must wait until the last issued token has expired.
Honor Retry-After on 429 and verification-in-progress responses. If a worker stops, retry after the lease expires. Retain a request ID for support; never log full keys or signed URLs. Files and audit data require operator-managed retention until automated cleanup is deployed.
Errors you can act on
{
"error": {
"code": "idempotency_conflict",
"message": "This idempotency key was used with a different payload.",
"requestId": "example-request-uuid"
}
}400 invalid fields/cursor/idempotency; 401 invalid key; 403 scope or issuer permission; 404 inaccessible or absent resource; 409 conflicting replay, verification lease or missing original; 410 expired upload; 413 upload or JSON too large; 415 wrong content type; 422 invalid original; 429 limit reached; 503 service unavailable.
Cross-workspace and disallowed project IDs return 404. An infrastructure error never exposes database errors, internal paths or credentials.