#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.
#5. Link entities
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> |
#Who may link and unlink
Links are authorized on the types of their two endpoints:
- Creating a link needs
readon both endpoint types, plusupdateorcreateon the type of at least one endpoint that is your own tenant's row. - Removing a link (
DELETE /entity/relationships/{id}, orPOST /entity/relationships/bulk-delete) needsreadon both endpoint types, plusupdateon the type of at least one endpoint that is your own tenant's row.createalone lets you add a link but not remove it: the removal answers403(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
403whatever 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.
#Links to shared rows
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'snext_cursor; withoutsort, rows come newestcreated_atfirst); offset (page/limit) is the escape hatch — the two are mutually exclusive.includeloads one hop of related entities by related type name (the engine resolves the connecting rule(s), in either direction); narrow to a specific rule withinclude_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/queryand/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 optionalsortsub-object picks which row wins each group (default: most recently updated). Everydistinct_onpath must be an indexed attribute (a400names one that isn't). A distinct result pages withpageonly: it returns nonext_cursor, and sending acursoralongsidedistinct_onis a400.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. Anumericorbooleanentry has to match the attribute's schema type — see Matchingtypeto the schema.filterable(defaulttrue) — 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 infilter, indexed or not; a non-indexed attribute is matched against the stored value directly — correct but unindexed (slower at scale). Mark an attributefilterable(or just indexed) once you actually query it often. A filterablenumericortimestamptzattribute is indexed for equality and range filters within your tenant; a filterablebooleangets no index of its own — its filter is checked row by row after the tenant's other indexes narrow the scan.sortable(defaultfalse, max 10 per type) — gives the attribute a sort index, so asorton it stays fast at scale, and exposes it to GraphQLorderByand OData$orderby./entity/querysorts on any attribute without it (see Sorting): an indexed attribute sorts by itstypeeither way, and one that isn't indexed sorts by its stored JSON value, without an index. Mark an attributesortableonce a list sorts on it routinely.searchable(defaultfalse, max 10 per type,textonly) — makesfuzzya real typo-tolerant word-similarity match on that attribute (and speeds uplike/segmenttoo). Without it,fuzzyon that attribute silently degrades to a plain substring check (no typo tolerance).- A path not listed in
indexed_attributesat all still works forfilterandsort(unindexed), but not forfuzzy's real similarity behavior, not for/entity/query/distinctordistinct_on, and not for thecs/ovfast 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 fastsort: [{"path": ["updated_at"], "direction": "desc"}]at scale.trueensures it,falsedrops 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 fastlike/ilike/fuzzyon the top-levelnamefield (as opposed to an indexedattributes.*). 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 marksrequiredhas 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 wantsPATCH /entity/entities/{id}/attributesinstead.
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
nullunless a later arm accepts it: a non-blank string survives when the union also hasstring, and"true"/"false"reach abooleanarm. A blank or whitespace-only string becomesnull. - The number arm runs first, so numeric-looking strings and real booleans both
become numbers on a union that includes
number. - Coercion looks at
typeonly. The coerced value is then validated against the whole property, so a string that survives on["string","number","null"]is still checked against amaxLength,pattern, orenumon that property. - To keep a scalar exactly as sent, declare the union with
anyOfinstead:{"anyOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}, {"type": "null"}]}. Coercion doesn't look insideanyOf, 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…"
}
subjectis the entity the change happened to: the one you asked about, or one reached throughinclude.event.from/event.toare the stored values; an attribute that was absent reads asnull, so a create's rows all havefrom: null.next_cursoris present only when an older page exists; its absence is the end of the history. A cursor is bound to the entity,includeandevent_pathsit was issued for: sent with any other, or malformed, it is refused with400(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/queryand 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
uploadTempFileaction, 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
fileform 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.