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 type is draft until you activate it; only active types accept entities.

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

#3. Add a relationship rule

A rule declares a directed connection between two types and its cardinality (max_from_cardinality / max_to_cardinality; null = unlimited). Cardinality is enforced when links are written.

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

#4. Create entities

attributes are validated against the type's schema. 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 returns 409 — use the upsert path instead.

POST/entity/entitiesCreate an entityAPI docs ↗Try it

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 "Cardinality constraint violated".
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

#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. Cross-tenant rows you can see through a share come back tagged source_type: "shared".

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); 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.

#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):

{
  "type_name": "Task",
  "include_specs": [
    {
      "rule_name": "ProjectHasTask",
      "type_name": "Project",
      "filter": {
        "operator": "eq",
        "path": ["attributes", "status"],
        "value": "active"
      },
      "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).
  • 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. The GraphQL playground also has a converter that turns a query into a workflow QueryEntity step.

#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
}
  • typetext | numeric | timestamptz | boolean | text_array. Governs which operators apply (the table above) and how the value is cast for comparison.
  • 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 via a JSONB fallback that's correct but unindexed (slower at scale, and not accelerated by a partial or composite index). Mark an attribute filterable (or just indexed) once you actually query it often.
  • sortable (default false, max 10 per type) — required for reliable sort. A non-indexed or non-sortable attribute path in sort is not rejected outright, but falls back to an unindexed text comparison — numbers and dates sort lexicographically in that fallback ("10" before "9"), not numerically/chronologically. Always mark an attribute sortable before sorting on it.
  • 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 (JSONB fallback) but not reliably for sort, not for fuzzy's real similarity behavior, and not for /entity/query/distinct or cs/ov fast paths — index anything on the querying hot path.

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.

Count strategy at scale. count_strategy: "estimate" (on /entity/query and /entity/query-graph) 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 a large batch in one transaction (capped per request — see the reference). 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. Useful options: skip_unresolved_relationships (skip + log an inline relationship whose target_identifier doesn't resolve, instead of failing the batch), merge_on_conflict / merge_cardinality (fold a colliding identifier_key into the surviving entity), and suppress_events.

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); …/export streams the inverse.

#Update, patch & delete

PUT replaces an entity; PATCH …/attributes deep-merges a partial attribute update; DELETE soft-deletes the entity and cascades to its relationship instances. Updates accept the same merge_on_conflict / merge_cardinality options as upsert.

PATCH/entity/entities/{id}/attributesPatch entity attributes (deep-merge)API docs ↗Try it

#Attach files (artifacts)

Files attach to an entity via a two-step flow: 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.

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

To hand a file back to a user, request a download URL for an artifact (POST …/artifacts/{artifactId}/download-url). The link 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, 7200 at most, and never longer than 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, promote them into a durable artifact on an entity with uploadArtifact. Nothing else preserves a temp file — when its lifetime ends it is gone.

#Sharing & access

Sharing your data model and rows with other tenants — definition shares, data shares, and grants — is its own topic. See Shares & Grants.

#API reference

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