Authorize

#Entities & Data Model

Entities are the records your application works with — a vessel, a task, a customer. Every entity belongs to an entity type, which pins its shape to a JSON schema and declares which attributes are queryable. Entities connect to one another through relationship rules (e.g. Project → Task). See Core Concepts for the mental model.

Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar at the top of this page and every Try it below uses it. Each example body is pre-filled; the console targets staging by default. Full schemas and field docs are in the linked API reference.

#Build a data model

The flow is always the same: schema → entity type → relationship rule → entities → links.

#1. Define a JSON schema

A schema describes an entity's attributes. Reuse one schema across many types.

POST/schema/schemasRegister a JSON schemaAPI docs ↗Try it

#2. Create an entity type

The type binds a name to a schema and declares config.identifier_key — the attribute path(s) that derive an entity's stable business key, computed server-side on every write — and config.indexed_attributes — the attributes that get a real index, so filtering, sorting, and searching on them stays fast at scale (see Entity type config & query capabilities for the full contract). A new type is active and accepts entities straight away.

An identifier_key path with on_null: "raise" must be listed in the schema's required array (a 422 names it otherwise). The computed key is normalised by transform — alphanumeric (default) lower-cases it and keeps letters and digits, none keeps it as-is — and stored pipe-delimited, so TASK-1024 becomes |task1024|.

POST/entity/entity-typesCreate an entity typeAPI docs ↗Try it

#3. Add a relationship rule

A rule declares a directed connection between two types, both of its labels (semantic_label, inverse_semantic_label — required), and its cardinality: max_from_cardinality caps how many links one from entity can have under the rule, max_to_cardinality how many one to entity can have (null = unlimited). Cardinality is enforced when links are written. Both types must belong to your tenant: a rule naming a type another tenant shares with you is refused with 422 (details.code: "NOT_OWNED"). To link your rows to another tenant's rows, that tenant authors the rule and shares it (see Links to shared rows).

POST/entity/relationship-rulesCreate a relationship ruleAPI docs ↗Try it

#4. Create entities

attributes are validated against the type's schema, after each value is coerced toward the schema's type — "42" sent to a number attribute is stored as 42 (see Type coercion on writes). identifier_key is never sent by the caller — it's computed server-side from config.identifier_key (here, the key attribute) and returned on every response. A create whose computed identifier_key collides with a live entity answers 409 and writes nothing — use the upsert path to update by key.

POST/entity/entitiesCreate an entityAPI docs ↗Try it

You can't choose an entity's id. The platform assigns every entity's id when it creates the row. A create body has no id field: an id you send anyway is ignored, and the 201 carries the id the row was given. An id in an upsert row only ever names a live row to update (see Rows by id). Create's other refusals: 404 for an entity_type_id you can't see, 422 (VALIDATION_FAILED) for attributes the schema refuses, and 403 (FORBIDDEN) without create on the type.

Link two existing entities via a rule. on_cardinality_violation chooses what happens when the link would exceed a rule's cap:

Value What happens
raise Default. Refuse the write with 409; nothing in the call is written.
replace Soft-delete the oldest existing link to make room, then write this one.
ignore Skip the write and carry on. A single create answers 422 naming this.
POST/entity/relationshipsLink two entitiesAPI docs ↗Try it

A new link answers 201 with the instance, its rule (under type), and both endpoints, each tagged source_type: "owned" (your tenant's row) or "shared" (a row another tenant shares with you). Nothing is written when a link is refused:

Refusal Status code Notes
The same link already exists under the rule 409 CONFLICT
Over the rule's cap with raise 409 CONFLICT details.error names the entity and the cap
Over the rule's cap with ignore 422 VALIDATION_FAILED Nothing was created
The rule doesn't exist, or you can't see it 404 NOT_FOUND You see a rule when you can read both of its types
An endpoint id that names no entity 404 NOT_FOUND details.error: Relationship endpoint not found
An endpoint that isn't of the rule's type at its side 404 NOT_FOUND details.error names the endpoint (From entity not found… / To entity not found…)
You may not write the link (below) 403 FORBIDDEN details.error: Permission denied: caller cannot write relationships for rule <rule id>

Links are authorized on the types of their two endpoints:

  • Creating a link needs read on both endpoint types, plus update or create on the type of at least one endpoint that is your own tenant's row.
  • Removing a link (DELETE /entity/relationships/{id}, or POST /entity/relationships/bulk-delete) needs read on both endpoint types, plus update on the type of at least one endpoint that is your own tenant's row. create alone lets you add a link but not remove it: the removal answers 403 (FORBIDDEN, "You do not have permission to delete this relationship instance.").
  • At least one endpoint must be your own row. A link between two rows that other tenants share with you is refused with 403 whatever your grants say.

A role that reads and writes every entity type, such as tenant_admin, meets all of this for your own types. For a policy-bound token, grant read on both types and update (or create, for a token that only adds links) on yours.

You can link your own row to a row another tenant shares with you. The rule belongs to that tenant (a rule's two types are always its author's), so they share three things with you in a manifest: the rule (relationship_rule), the type your side of the link uses (entity_type, so you can store your own rows of it), and the rows you link to (entity). Then create the link as usual: read on the shared type comes with the share and your grants, and update or create on the type of your own endpoint authorizes it; removing it needs update there. See Shares & Grants.

#Read & query

POST /entity/query is the read surface for anything non-trivial. The body is a filter tree plus sort, pagination, and relationship includes. Rows another tenant shares with you come back alongside your own, once you can read them (see Sharing & access).

POST/entity/queryQuery entitiesAPI docs ↗Try it
  • Path conventions: ["name"] / ["id"] / ["created_at"] / ["updated_at"] (system fields); ["attributes", "x"] (an attribute, indexed or not — see below); ["relationships", "<TypeName>", "attributes", "x"] (filter through a relationship, resolved in either direction); ["relationships", "rule", "name"] (filter by the connecting rule's name instead of the related type).
  • Pagination is keyset by default (cursor, from a previous response's next_cursor; without sort, rows come newest created_at first); offset (page/limit) is the escape hatch — the two are mutually exclusive. include loads one hop of related entities by related type name (the engine resolves the connecting rule(s), in either direction); narrow to a specific rule with include_specs.rule_name.

#Sorting

sort is a list of { "path": [...], "direction": "asc" | "desc" } (default asc), applied in order. It works on ["id"], ["created_at"], ["updated_at"], and on any attribute path, indexed or not, nested paths included. How an attribute compares depends on whether it is listed in config.indexed_attributes:

Attribute Compared as
Indexed (any flags) The entry's type: numeric by value, timestamptz chronologically (across UTC offsets), boolean false before true, text as text.
Not indexed The stored JSON value: numbers by value (1, 2, 10), text as text, booleans false before true. A date kept as an ISO 8601 string sorts as text, which is chronological for one fixed format.
Mixed JSON types (not indexed) Values of different JSON types don't interleave — they group by type: ascending puts text values first, then numbers, then booleans; descending reverses the groups.
Missing key or null (either) Always last, in both directions.

A sort on an attribute that isn't indexed is correct but unindexed: every matching row is sorted per request, so it slows down as the type grows. Keyset cursor paging works on it. Once a list sorts on an attribute routinely, index it with sortable: true (see Entity type config & query capabilities).

["name"] and ["identifier_key"] are filter paths only: in sort they are ignored and the rows come back in the default order.

GraphQL orderBy and the OData $orderby option are stricter: they only offer attributes indexed with sortable: true (OData also takes created_at / updated_at). See Querying & GraphQL and PowerBI & OData.

#Filter operators

Every particle filter is { "operator": ..., "path": [...], "value": ... }, combined with a composite { "operator": "and" | "or" | "not", "filters": [...] } (nestable up to 25 levels deep).

Operator Applies to Meaning
eq / neq any Exact (in)equality. Text comparison is case-sensitive.
is any Null check (value: null) or boolean check (value: true/false).
gt / gte / lt / lte numeric, timestamptz, text Range comparison — numeric/date ordering for those types, lexicographic for text.
in any Value is one of an array of scalars.
like / ilike text Pattern match with % wildcards. like is case-sensitive, ilike is not; both are accent-insensitive.
segment text Matches a value stored as a pipe-delimited string (|seg1|seg2|) — e.g. filtering identifier_key by one segment.
fuzzy text Typo-tolerant match (word-similarity scoring). Optional threshold (0–1, default 0.6). Needs searchable: true on the attribute for real similarity scoring — see below.
cs text_array Contains-all: the array attribute contains every value in value.
ov text_array Contains-any: the array attribute shares at least one value with value.
exists relationship {"path": ["relationships", "<TypeName>"]} — entity has at least one relationship of that type.
rel_exists relationship Like exists, but with a nested value filter that every leaf must match on the same related row (a conjoint-row predicate — different from ANDing several ["relationships", ...] filters, which can each match a different related row).
// "Vessels with an expired Ship Safety certificate" — conjoint on ONE cert row
{
  "operator": "and",
  "filters": [
    { "operator": "gt", "path": ["attributes", "dwt"], "value": 10000 },
    {
      "operator": "rel_exists",
      "path": ["relationships", "VesselCertificate"],
      "value": {
        "operator": "and",
        "filters": [
          {
            "operator": "eq",
            "path": ["attributes", "type"],
            "value": "Ship Safety Certificate"
          },
          {
            "operator": "lt",
            "path": ["attributes", "expiry_date"],
            "value": "2026-05-25"
          }
        ]
      }
    }
  ]
}

#Relationship includes: include vs include_specs

include is the simple form — an array of related entity type names, loading one hop with no further control. include_specs is the object form for the same one hop, with per-relationship filter, sort, limit, and optional rule_name narrowing (when two rules connect the same pair of types). An include's filter is always a composite (and / or / not with filters), and its sort is a single { "path", "direction" } object (default desc):

{
  "type_name": "Task",
  "include_specs": [
    {
      "rule_name": "ProjectHasTask",
      "type_name": "Project",
      "filter": {
        "operator": "and",
        "filters": [
          {
            "operator": "eq",
            "path": ["attributes", "status"],
            "value": "active"
          }
        ]
      },
      "sort": { "path": ["attributes", "budget"], "direction": "desc" },
      "limit": 1
    }
  ]
}

/entity/query only hydrates one hop — an include_specs entry with its own nested include is rejected (422) on this endpoint. For a whole related subtree in one call, use /entity/query-graph below, whose include accepts the same per-entry filter/sort/limit plus nesting.

#Distinct values vs. distinct rows

Two different tools, easy to conflate:

  • distinct_on (a field on /entity/query and /entity/query-graph) — SELECT-DISTINCT-ON semantics: one representative entity per unique combination of up to 5 attribute paths, e.g. {"paths": [["attributes", "region"], ["attributes", "status"]]}. An optional sort sub-object picks which row wins each group (default: most recently updated). Every distinct_on path must be an indexed attribute (a 400 names one that isn't). A distinct result pages with page only: it returns no next_cursor, and sending a cursor alongside distinct_on is a 400.
  • POST /entity/query/distinct — a separate endpoint returning distinct values of a single attribute ({"type_name", "attribute_name"}), for building filter dropdowns or autocomplete. The attribute must be indexed.

#Deep relationship reads — /query-graph

/entity/query reads one hop deep. For a whole related subtree in a single call, use POST /entity/query-graph — the same type_name / filter / sort / pagination shape, plus a nested include tree (each entry can itself carry include, filter, sort, limit, to any depth up to a server cap). Instead of a row-per-parent shape, the response is one deduplicated graph: roots (matching entity ids), nodes (every reachable entity, keyed by id — a node shared by two branches is serialized once), and edges. This is the REST-native way to do a graph-shaped read without GraphQL, and it's the surface most integrations reach for by default.

POST/entity/query-graphQuery a graph of entities (deep hydration)API docs ↗Try it

If the node budget constrains a deeply-nested branch, page_info.truncated is true and page_info.truncations reports which branch and at what depth — narrow that branch's limit, prune it, or paginate the roots and re-issue.

For a GraphQL surface over the same access rules (selective fields, Relay Connections, codegen) see Querying & GraphQL.

#Entity type config & query capabilities

config.indexed_attributes (set at entity-type creation, patchable later) is an array of up to 20 entries, each:

{
  "path": ["due_date"],
  "type": "timestamptz",
  "filterable": true,
  "sortable": false,
  "searchable": false
}
  • type — text | numeric | timestamptz | boolean | text_array. Governs which operators apply (the table above) and how the value is cast for comparison and sorting. A numeric or boolean entry has to match the attribute's schema type — see Matching type to the schema.
  • filterable (default true) — gives the attribute a real index for filtering. Filtering itself is not gated on this — any attribute in the type's JSON schema can be used in filter, indexed or not; a non-indexed attribute is matched against the stored value directly — correct but unindexed (slower at scale). Mark an attribute filterable (or just indexed) once you actually query it often. A filterable numeric or timestamptz attribute is indexed for equality and range filters within your tenant; a filterable boolean gets no index of its own — its filter is checked row by row after the tenant's other indexes narrow the scan.
  • sortable (default false, max 10 per type) — gives the attribute a sort index, so a sort on it stays fast at scale, and exposes it to GraphQL orderBy and OData $orderby. /entity/query sorts on any attribute without it (see Sorting): an indexed attribute sorts by its type either way, and one that isn't indexed sorts by its stored JSON value, without an index. Mark an attribute sortable once a list sorts on it routinely.
  • searchable (default false, max 10 per type, text only) — makes fuzzy a real typo-tolerant word-similarity match on that attribute (and speeds up like / segment too). Without it, fuzzy on that attribute silently degrades to a plain substring check (no typo tolerance).
  • A path not listed in indexed_attributes at all still works for filter and sort (unindexed), but not for fuzzy's real similarity behavior, not for /entity/query/distinct or distinct_on, and not for the cs/ov fast paths — index anything on the querying hot path.

#Matching type to the schema

The indexed copy of an attribute is stored as the entry's type, whatever the JSON schema allows. A value that type cannot hold — "abt 30,000 MT" in a numeric column — would fail the whole write it arrives in, so the entity type's config is checked against its schema when you create the type, update it, or patch its config:

type Every schema type at path must be Typical fix when rejected
numeric number, integer, or null Index as text, or drop string from the schema
boolean boolean or null Index as text, or narrow the schema
text anything (not checked) —
text_array anything (not checked) —
timestamptz not checked Keep the attribute an ISO 8601 date-time string

A property's allowed types are the intersection of every constraint it carries: its own type, the union of its anyOf branches, the union of its oneOf branches, and each allOf branch. integer narrows number. A union with an untyped branch, or a $ref, constrains nothing, and a property whose types nothing pins down is not checked — give it an explicit type if you index it as numeric or boolean.

A rejection is a 422 naming each offending entry:

{
  "error": {
    "status": 422,
    "code": "VALIDATION_FAILED",
    "message": "Entity type config validation failed: configured paths are invalid against the schema",
    "details": {
      "errors": [
        {
          "path": "config.indexed_attributes[0].type",
          "configured_path": ["qty"],
          "message": "Field 'qty' is indexed as 'numeric', but its schema also allows 'string', which cannot be stored in a numeric column. Index it as 'text' or narrow the schema type."
        }
      ]
    }
  }
}

The same 422 names an indexed_attributes or identifier_key path that the schema doesn't declare.

A config change that adds or changes an indexed attribute also starts a background reindex of the type's existing entities, reported as meta.provisioning_job_id on the response. Entities written afterwards are indexed on write; until the reindex finishes, filters and sorts on the new entry may miss older rows. Track it with GET /entity/entity-types/{id}/sync-status (status, pct_complete). The check runs when the type's config is written; editing the JSON schema later (say, adding string to an attribute indexed as numeric) is not re-checked, so review the indexed attributes whenever you widen a schema type.

Alongside indexed_attributes, two type-level flags tune search and sort:

  • sort_by_updated_at (tri-state boolean) — opt a type into fast sort: [{"path": ["updated_at"], "direction": "desc"}] at scale. true ensures it, false drops it, omitted leaves it as-is. Turn it on for types whose UIs list "most recently changed first" over large row counts.
  • name_search (tri-state boolean) — opt a type into fast like / ilike / fuzzy on the top-level name field (as opposed to an indexed attributes.*). Same tri-state semantics. Turn it on when you search entities by name over large row counts.

Counts. Send include_count: true to get a total — count on /entity/query, page_info.total_count on /entity/query-graph. count_strategy picks how it is computed — "capped" (default), "estimate", or "exact". "estimate" returns a fast, per-tenant row estimate — it can be slightly stale but is scoped to your tenant, not the whole store. Use it when a UI needs an approximate total quickly; use "exact" only for small result sets.

An attribute path that doesn't exist at all (typo, or never in the schema) is not validated away — a filter on it just matches nothing rather than erroring. Double-check spelling before assuming a query is "empty."

#Bulk upsert & import

POST /entity/entities/upsert writes up to 100,000 rows of one type (named by entity_type_id or entity_type_name) in one call. Each row matches an existing entity on its computed identifier_key (derived from attributes.key per the type's config above) — never a caller-supplied identifier_key. on_conflict decides what happens to a row that matches: replace (default — the sent attributes replace the stored ones, so an attribute you leave out is dropped), merge (the sent attributes are merged over the stored ones, so an attribute you leave out is kept), ignore (keep the stored row; the response reports it with operation_type: "ignored"), or raise (answer 409 and write nothing from the commit). Other useful options: merge_on_conflict / merge_cardinality (fold a colliding identifier_key into the surviving entity when a by-id row changes its key), and suppress_events.

How a call commits. Every row is checked against the type's schema before anything is written, so a row the schema refuses (422) leaves the whole call unwritten. The rows are then written in order, in commits of 1,000 rows, each all or nothing. A refusal inside a commit (a raise conflict, an unknown id, a stale expected_updated_at, a permission refusal) writes nothing from that commit and ends the call, but the commits before it stay written: an unknown id or a permission refusal names the refused commit's first row in details.batch_start (its index in data), so the rows before it are the ones written. Send at most 1,000 rows when a call must land whole or not at all.

#Rows by id

A row that carries an id updates the live entity of that type with that id and never creates one — ids are the platform's to assign. An id that names no live entity of the type — an id you made up for a new row, a deleted row, a row of another type, or another tenant's row you read through a share — answers 404 (NOT_FOUND) with the refused ids in details.unwritable_ids, and nothing in that commit is written:

{
  "error": {
    "status": 404,
    "code": "NOT_FOUND",
    "message": "Entity not found with id: aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
    "details": {
      "unwritable_ids": ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"],
      "batch_start": 0
    }
  }
}

To create, omit id and let the row match by key. On a by-id row merge lays the sent attributes over the stored ones and every other on_conflict value replaces them; a by-id row never raises 409 and is never ignored.

Rows of one commit whose computed identifier_key is the same are folded into one before the write, and the last of them wins — under every on_conflict, raise included (raise refuses a key a live entity already holds, not a key repeated in the request). The response lists the folded row once.

Set expected_updated_at on a row to the updated_at you last read: if the stored row has changed since, the call answers 409 with details.code: "CONCURRENT_MODIFICATION" and the row's current_updated_at.

#Inline relationships

A row's relationships links it as it is written, to a target named by target_entity_id, or by target_identifier (the target's identifier attributes) plus target_type_name; direction (from, the default, or to) says which side of the rule the row is on. The links are written after the rows, and a rule's cardinality cap is met by replacing the oldest link. When a link is refused the call answers with its status — 422 with details.unresolved_targets for a target_identifier that matches no entity — but the rows are already written. skip_unresolved_relationships: true skips (and logs) an unresolved target instead.

#Upsert refusals

Refusal Status code details
Neither entity_type_id nor entity_type_name, or over 100,000 rows 422 VALIDATION_FAILED errors
The type doesn't exist or you can't see it 404 NOT_FOUND entity_type_name
A row the schema refuses (nothing in the call is written) 422 VALIDATION_FAILED errors
on_conflict: "raise" and a row's key is held by a live entity 409 CONFLICT identifier_key
A row's id names no live entity of the type 404 NOT_FOUND unwritable_ids, batch_start
A row's expected_updated_at is stale 409 CONFLICT code: "CONCURRENT_MODIFICATION", current_updated_at, expected_updated_at
A by-id row changes its key to one another entity holds, merge_on_conflict: false 409 CONFLICT
The same, merging, and merge_cardinality: "raise" would exceed a rule's cap 422 VALIDATION_FAILED The message names the surviving entity, the rule and the cap
No create / update on the type 403 FORBIDDEN batch_start
An inline target_identifier matches no entity (rows already written) 422 VALIDATION_FAILED unresolved_targets

on_conflict: "merge" still validates what you SEND. The payload is checked against the type's attribute schema before any existing row is read, so every attribute the schema marks required has to be present in the request — even one you are not changing and the stored row already has. Merging affects what is written, not what is validated. A partial update that cannot restate the required attributes wants PATCH /entity/entities/{id}/attributes instead.

POST/entity/entities/upsertBulk upsert entitiesAPI docs ↗Try it

For files, POST /entity/entities/import streams a CSV (an X-Import-Config header carries the column mapping + on_conflict strategy). The way back out is Export (CSV & XLSX).

#Type coercion on writes

Before attributes are validated, each value is coerced toward the schema's type, so what is stored can differ in JSON type from what you sent: "15" sent to a number attribute is stored as 15, and 11 sent to a string attribute as "11". A value that can't be coerced fails validation ("abc" for a number is a 422). On a union type array the arms are tried in a fixed order — number, then boolean, then string — and the first that accepts the value wins:

Schema type Sent Stored
["string","number","null"] "30000" 30000 (number)
["string","number","null"] "abt 30,000 MT" "abt 30,000 MT"
["string","number","null"] "" or " " null
["string","number","null"] true 1
["number","null"] "not a number" null
["number","null"] "42" 42
["number","boolean","null"] "true" / "false" true / false
["number","boolean","null"] true / false 1 / 0
["string","null"] " " " " (kept)

Rules worth knowing:

  • On a nullable numeric union, a value that isn't a number becomes null unless a later arm accepts it: a non-blank string survives when the union also has string, and "true" / "false" reach a boolean arm. A blank or whitespace-only string becomes null.
  • The number arm runs first, so numeric-looking strings and real booleans both become numbers on a union that includes number.
  • Coercion looks at type only. The coerced value is then validated against the whole property, so a string that survives on ["string","number","null"] is still checked against a maxLength, pattern, or enum on that property.
  • To keep a scalar exactly as sent, declare the union with anyOf instead: {"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}, {"type": "null"}]}. Coercion doesn't look inside anyOf, and validation still refuses objects and arrays.

POST /schema/validate applies the same coercion, and its response is the coerced data — a quick way to see what a write would store.

#Export (CSV & XLSX)

Two routes turn a filtered, sorted set of entities of one type into a file:

  • POST /entity/entities/export — CSV, streamed row by row. No size cap.
  • POST /entity/entities/export-xlsx — a single-sheet Excel workbook, built in full before it is sent, so it is capped (below).

Both take the same body. type_name, filter, and sort work as in /entity/query; columns picks what to write:

Field Meaning
columns Required. Header → source. A source is id, name, identifier_key, created_at, updated_at, or an attribute path such as attributes.imo_number or attributes.specs.length. Anything else is a 400.
column_order Headers to write first, in this order; headers you leave out follow. A header that isn't a key of columns is a 400.
array_strategy How an array value fills one cell: first_only (default), json_array, or comma_separated.
limit Stop after this many entities.
batch_size Rows read per page while walking the export, up to 1000.
output_filename Download filename. Defaults to <type_name>_export_<timestamp>.csv / .xlsx.
delimiter CSV only. Default ,.
sheet_name XLSX only. Worksheet tab name; defaults to type_name.

name as a source is the entity's own name field; to export an attribute called name, use attributes.name. columns is a JSON object, and a stored object (in a workflow or job definition) does not keep its key order — list the headers in column_order whenever the column order matters.

POST/entity/entities/export-xlsxExport entities to an XLSX workbookAPI docs ↗Try it

The response body is the file itself, with no JSON envelope:

Header CSV XLSX
Content-Type text/csv; charset=utf-8 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition attachment; filename="…" attachment; filename="…"
X-Row-Count Rows written Rows written

The CSV has a header row. Numbers and booleans are written as-is, objects as JSON text, and missing values as empty fields; a text value that starts with =, +, - or @ is prefixed with ' so a spreadsheet opening the file treats it as text. The workbook has a bold header row and keeps numbers and booleans typed; objects are JSON text, and missing values are empty cells.

An XLSX export is refused with a 400 before any file is sent when it would exceed one of these limits. details carries the counts that tripped it.

Limit Value details
Columns 16 384 (Excel's own maximum) type_name, column_count, column_limit
Cells 250 000 = rows (after limit) × columns type_name, row_count, column_count, cell_count, cell_limit
Cell text 4 000 000 characters in total, headers included type_name, row_count, column_count, character_count, character_limit
{
  "error": {
    "status": 400,
    "code": "BAD_REQUEST",
    "message": "Export of 16 rows x 16384 columns (262144 cells) exceeds the XLSX limit of 250000 cells. Narrow the filter, drop columns, or use exportEntitiesToCsv.",
    "details": {
      "type_name": "Task",
      "row_count": 16,
      "column_count": 16384,
      "cell_count": 262144,
      "cell_limit": 250000
    }
  }
}

Narrow the filter, set a limit, or drop columns — or export CSV, which has no cap. From a workflow or job, the exportEntitiesToCsv and exportEntitiesToXlsx actions do the same thing; see Workflows.

#Read, update & delete by id

GET /entity/entities/{id} reads one entity (include adds related entities by type name, as on /entity/query). PUT replaces an entity's attributes, and its name when you send one. PATCH …/attributes merges a partial update over the stored attributes one level deep: each top-level attribute you send replaces the stored one, a nested object included, and every attribute you leave out is kept. DELETE soft-deletes the entity and cascades to its relationship instances and artifacts. PUT accepts the same merge_on_conflict / merge_cardinality options as upsert.

#The type scope

The by-id routes take the entity's type as an optional scope: entity_type_id, or type_name when you have the name but not the id (entity_type_id wins when both are sent); a link's by-id routes take its rule as rule_id. No route requires a scope — without one the entity or link is found by id alone — but a scoped read looks in that one type or rule only and is the faster read, so send it whenever you know it.

Route Scope
GET /entity/entities/{id} entity_type_id or type_name (query)
PUT /entity/entities/{id} entity_type_id or type_name (query)
PATCH /entity/entities/{id}/attributes entity_type_id or type_name (query)
DELETE /entity/entities/{id} entity_type_id or type_name (query)
GET /entity/entities/{id}/timeline entity_type_id or type_name (query)
POST /entity/entities/bulk-delete entity_type_id or type_name (body)
GET /entity/entities/{id}/artifacts entity_type_id (query)
POST …/{id}/artifacts/upload-url entity_type_id (body)
GET /entity/relationships/{id} rule_id (query) — the link's rule
DELETE /entity/relationships/{id} rule_id (query)
POST /entity/relationships/bulk-delete rule_ids (body) — the links' rules
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  "https://api.helm.bridge-labs.com/entity/entities/$TASK_ID?type_name=Task"

curl -u "$CLIENT_ID:$CLIENT_SECRET" -X PATCH -H "Content-Type: application/json" \
  "https://api.helm.bridge-labs.com/entity/entities/$TASK_ID/attributes?entity_type_id=$TASK_TYPE_ID" \
  -d '{"status": "done"}'

curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  "https://api.helm.bridge-labs.com/entity/relationships/$LINK_ID?rule_id=$RULE_ID"

A scope that doesn't hold the id never reaches into another type; the request is refused:

Scope sent Answer
The entity's own type As without a scope
A type that doesn't hold the id 404 NOT_FOUND, "Entity not found." (on the timeline: "Entity not found: <id>")
A type_name you can't see 404 NOT_FOUND, "Entity type not found or not accessible." (details.name)
A malformed entity_type_id 422 VALIDATION_FAILED
A rule that doesn't hold the link (rule_id) 404 NOT_FOUND, "Relationship instance not found"
Artifact routes: a type that doesn't hold the id 404 NOT_FOUND, "Entity not found: <id>", as for an id that names no entity
Bulk delete: a type that doesn't hold an id 404 NOT_FOUND, "One or more entities not found."
PATCH/entity/entities/{id}/attributesPatch entity attributes (top-level merge)API docs ↗Try it

#Entity timeline

GET /entity/entities/{id}/timeline is one newest-first stream of the changes recorded for an entity — and, when you ask, for the entities it is linked to — with one row per changed attribute. A create that set four attributes is four rows; a PATCH that changed one is one row.

GET/entity/entities/{id}/timelineRead an entity's timelineAPI docs ↗Try it
Query parameter Meaning
include Comma-separated relationship rule names. Every entity those rules link to (either direction) adds its own changes to the stream. A name that matches no rule adds none.
event_paths Comma-separated attribute paths below attributes (status, port.code) to keep. Absent, every changed attribute is a row.
limit Rows per page, 1–200. Default 20.
before A cursor: a previous page's next_cursor, or any row's cursor. Returns the rows strictly older than it.
include_principals true resolves each row's actor. Without it every actor is { "kind": "none" }.
entity_type_id / type_name The type scope.
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  "https://api.helm.bridge-labs.com/entity/entities/$TASK_ID/timeline?type_name=Task&include=ProjectHasTask&limit=5&include_principals=true"
{
  "rows": [
    {
      "id": "4fa21d1c-f6b9-4cbd-8f44-41fd4ffcdb89:0000000002",
      "at": "2026-10-06T00:49:51.177893+00:00",
      "cursor": "eyJpZCI6IjRmYTIx…",
      "actor": {
        "kind": "user",
        "id": "8bd0bb9e-10a5-48d4-9d5d-162cd1b5a20f",
        "label": "Casey Engineer",
        "email": "casey@acme.com",
        "external_id": null
      },
      "subject": {
        "type": "Task",
        "id": "25f397c5-da27-4d9c-8065-6444e44d6c66",
        "key": "|task1024|"
      },
      "event": { "path": "status", "from": "done", "to": "open" }
    },
    {
      "id": "43e0d56a-1ccd-4678-b813-3020560e0237:0000000003",
      "at": "2026-10-06T00:49:23.355765+00:00",
      "cursor": "eyJpZCI6IjQzZTBk…",
      "actor": { "kind": "none" },
      "subject": {
        "type": "Project",
        "id": "3bff583b-9486-43b5-a947-4f109f98e6c3",
        "key": "|prj8|"
      },
      "event": { "path": "status", "from": null, "to": "active" }
    }
  ],
  "next_cursor": "eyJpZCI6IjQzZTBk…"
}
  • subject is the entity the change happened to: the one you asked about, or one reached through include.
  • event.from / event.to are the stored values; an attribute that was absent reads as null, so a create's rows all have from: null.
  • next_cursor is present only when an older page exists; its absence is the end of the history. A cursor is bound to the entity, include and event_paths it was issued for: sent with any other, or malformed, it is refused with 400 (BAD_REQUEST, "Invalid timeline cursor…").
  • The stream holds exactly the changes you could read directly: changes to an entity you can't read are not in it.
  • Comments and other records you keep as entities are not part of the stream; read them with /entity/query and merge by time.

An id that isn't a UUID is a 400 (BAD_REQUEST), as on every by-id entity route; a limit over 200 is a 422 (VALIDATION_FAILED); an entity you can't see, or a scope that doesn't hold it, is a 404.

#Attach files (artifacts)

Files attach to an entity in three steps: request an upload URL, PUT the bytes to it, then confirm. Artifacts are encrypted at rest and access is gated by the same permissions as the entity.

1. Request an upload URL. artifact_type is your own label for the kind of file (specification, invoice, …); file_name becomes the download filename.

POST/entity/entities/{id}/artifacts/upload-urlRequest an artifact upload URLAPI docs ↗Try it

The response carries artifact_id, artifact_version, a signed upload_url, and its expires_at (two hours).

2. PUT the bytes to upload_url — no Helm credentials, just the file body with a matching Content-Type:

curl -X PUT -H "Content-Type: application/pdf" \
  --data-binary @spec.pdf "$UPLOAD_URL"

3. Confirm the upload. The body is optional ({ "checksum": "…" }); the response reports the stored size and content_type.

POST/entity/entities/{id}/artifacts/{artifactId}/confirmConfirm an artifact uploadAPI docs ↗Try it

GET /entity/entities/{id}/artifacts lists an entity's artifacts (filter with artifact_type). To hand a file back to a user, request a download URL:

GET/entity/entities/{id}/artifacts/{artifactId}/download-urlGet an artifact download URLAPI docs ↗Try it

The download_url is signed and needs no credentials; it downloads as an attachment with the artifact's original filename.

#Temp files

When you need somewhere to put bytes that are not a stored artifact — a generated report, an on-the-fly export, a file a user just picked in an app — use a temp file. Where an artifact belongs to an entity and lives until you delete it, a temp file has no owning entity, its lifetime is capped at 24 hours, and it disappears on its own when that lifetime runs out.

Temp files are produced, not POSTed by hand. Two things create them:

  • a job's uploadTempFile action, which stores a stream, a raw string or a JSON value and returns the file's id together with a download URL;
  • an app's file form field, where the browser uploads straight into a temp file and the field's value becomes its id (see Apps).

Either way you end up holding a temp file id, and there are three things to do with it.

Read the bytes back. From a job, the downloadTempFile action reads a temp file into the run's stream context — the counterpart of downloadArtifact — and as: "text" decodes it in memory instead. Directly over the API:

GET/entity/temp-files/{id}Stream a temp file's bytesAPI docs ↗Try it

The recorded name and media type come back in the X-Temp-File-Name and X-Temp-File-Content-Type headers. The response's own Content-Type is always application/octet-stream, so a stored type can never drive how a browser renders it.

Hand it to someone. Mint a link that downloads without any credentials until it expires — from a job with the createTempFileDownloadURL action, or directly:

POST/entity/temp-files/{id}/download-urlMint a credential-free download linkAPI docs ↗Try it

ttl is seconds — 900 by default, capped at 7200 and at the temp file's own remaining life. The URL is a bearer capability: anyone holding it can download until expires_at, so treat it like a secret and keep the TTL short.

Keep it. If the bytes need to outlive the window, make them a durable artifact: on an entity you already have with a job's uploadArtifact action, or as the artifact of a new entity with POST /entity/entities/from-temp-files. Nothing else preserves a temp file — when its lifetime ends it is gone.

#Create entities from temp files

POST /entity/entities/from-temp-files turns up to 20 temp files into entity rows that each carry their file as an artifact — what a form that took files does on submit. Each item names the temp file and the row it becomes; the rows, their artifacts and their links are written all or nothing.

POST/entity/entities/from-temp-filesCreate entities from temp filesAPI docs ↗Try it
Field Required Meaning
items[] Yes 1–20 items. A temp file may appear once per call.
items[].type_name Yes The new row's entity type.
items[].temp_file_id Yes A live temp file you uploaded.
items[].attributes No The row's attributes, before the two below are added.
items[].name_attribute No The attribute that receives the temp file's name (spec.pdf). Must differ from artifact_attribute.
items[].artifact_attribute No The attribute that receives the new artifact's id.
items[].artifact_type No The artifact's type label: 1–64 letters, digits, _ or -. Default upload.
items[].relationships[] No Up to 10 links { rule_name, target_entity_id, direction?, attributes? }. direction: "from" (default) puts the new row on the rule's from side.
on_conflict No For a row whose key matches a live row: merge (default), replace or raise, as on upsert. ignore is not offered.
suppress_events No As on upsert.

Each row is checked against its type's schema as it will be stored — with the file name and artifact id already in place — so a schema that requires those attributes is satisfied by the call itself. Rows are written as upsert writes them (computed identifier_key, on_conflict, events); a link meets its rule's cardinality cap by replacing the oldest link, and an identical link that already exists is left as it is. The temp file itself stays where it is until its lifetime ends.

The response lists the rows in item order, each as upsert returns it plus reused and its artifact:

{
  "data": [
    {
      "id": "875b5e2a-b154-48e4-8374-654d77cd3632",
      "name": null,
      "identifier_key": "|doc1|",
      "attributes": {
        "ref": "DOC-1",
        "category": "specification",
        "file_name": "spec.pdf",
        "file_artifact_id": "4df8f017-3c2f-4c18-9312-273e626f7a05"
      },
      "created_at": "2026-10-06T00:52:40.330158+00:00",
      "updated_at": "2026-10-06T00:52:40.330158+00:00",
      "type": {
        "id": "1cc0fc50-86ba-4901-98c5-02ad5819ca13",
        "name": "Document",
        "attributes_schema_id": "f35733a7-ecff-44be-bba6-1e29f7c9fffa"
      },
      "operation_type": "created",
      "reused": false,
      "artifact": {
        "id": "4df8f017-3c2f-4c18-9312-273e626f7a05",
        "version": 1,
        "artifact_type": "specification",
        "original_filename": "spec.pdf",
        "content_type": "application/pdf",
        "size": 26
      }
    }
  ]
}

The artifact is an ordinary one: list it with GET /entity/entities/{id}/artifacts and hand it out with its download-url.

A temp file becomes one row, once. Sending a call again — say, a form submit retried after a timeout — answers with the row and artifact each file already became, reused: true and operation_type: "unchanged", and writes nothing new for it (its links are written again, and a link that exists is left alone). That holds when you are the one who made the row, the item names the row's type, and you can still read the row; otherwise the file is refused with 409.

Permissions. A temp file is its uploader's: naming one someone else uploaded is refused with 403, whether or not it already became a row. Each row needs both create and update on its type — attaching the artifact is a write on the row, so create alone is refused with 403. Each link follows the link rules.

Nothing is written when the call is refused:

Refusal Status code details
A temp file that doesn't exist or has expired (and never became a row) 404 NOT_FOUND temp_file_ids
A type_name you can't see 404 NOT_FOUND entity_type_name
A temp file someone else uploaded; no create / update on a row's type; a link you may not write 403 FORBIDDEN
A link whose rule or target doesn't exist, or whose target isn't of the rule's type 404 NOT_FOUND
on_conflict: "raise" and a row's key is held by a live entity 409 CONFLICT identifier_key
A temp file already made into a row this call can't be answered with (see above) 409 CONFLICT code: "ALREADY_PROMOTED"
The same temp file was being made into a row by another call at the same moment — retry 409 CONFLICT retryable: true
A row its schema refuses 422 VALIDATION_FAILED errors, item_index
Over 20 items, a temp file named twice, name_attribute equal to artifact_attribute, a bad artifact_type, or on_conflict: "ignore" 422 VALIDATION_FAILED errors

#Sharing & access

Other tenants see your entity types and rows only through a manifest you offer them and they accept — an entity_type member shares the type, an entity member shares rows — and their own grants still decide what each of their principals can read. A share never lets the other tenant write your rows, but with a rule you share they can link their own rows to yours (see Links to shared rows). See Share with Another Tenant for authoring a manifest and Shares & Grants for the model.

#API reference

Every entity and schema operation, with full request/response schemas and an interactive console, is in the API reference.