Endpoints

Every door a key can open, and nothing else. Endpoints that exist but need a signed-in person, or a station credential, are not listed — they will return 401 or 403 for you no matter what you send.

All paths are relative to $BASE. All request and response bodies are JSON.

Pagination

Two endpoints page, and both work the same way:

  1. Call with limit (1–100).
  2. Read nextCursor from the response.
  3. If it is a string, pass it back as cursor for the next page.
  4. If it is null, you have the last page.

Cursors are opaque. Do not parse, compare or construct them; the only valid cursor is one the API just gave you. A full last page returns a non-null cursor, so the final request of a walk returns an empty items/resources array — that is normal, not an error.


Discovery

GET /v1/api-keys/self

What this key is and what it may touch. Takes no parameters. Every valid key may call it.

Field Type Meaning
apiKeyId number This key's id.
name string This key's name. It is also the producer recorded on everything you write, including the resources you create.
scopes object The raw scope sets: read_types, operate_types, find_or_create_types.
owner object | null { resourceId, name } — the organization that owns the key.
actsAt object | null Always null for a service key; used only by station credentials.

Errors: 401 invalid_api_key.

From self.json — status 200:

{
  "apiKeyId": 781,
  "name": "bid-app-docs",
  "scopes": {
    "read_types": [
      "cab_pricing_level",
      "cab_payment_terms",
      "cab_order",
      "cab_catalog_product",
      "cab_cat_accessory",
      "cab_cat_base",
      "cab_cat_molding",
      "cab_cat_office",
      "cab_cat_utility",
      "cab_cat_vanity",
      "cab_cat_wall",
      "cab_fam_base_sink",
      "cab_fam_bookcase",
      "cab_fam_crown_molding",
      "cab_fam_filler",
      "cab_fam_lazy_susan",
      "cab_fam_oven_cabinet",
      "cab_fam_panel",
      "cab_fam_post",
      "cab_fam_utility_cabinet",
      "cab_fam_vanity_combo",
      "cab_fam_wall_cabinet",
      "cab_fam_wall_drawer",
      "cab_fam_wall_pantry_pullout_filler",
      "cab_fam_wall_stacked",
      "door_style_lower",
      "door_style_upper",
      "drawer",
      "finish",
      "front",
      "hinge",
      "interior",
      "sheen",
      "slide",
      "species"
    ],
    "operate_types": [
      "cab_customer",
      "cab_bid",
      "cab_design_set",
      "cab_line",
      "cab_install",
      "cab_adjustment",
      "cab_modification"
    ],
    "find_or_create_types": [
      "cab_customer",
      "cab_bid",
      "cab_design_set",
      "cab_line",
      "cab_install",
      "cab_adjustment",
      "cab_modification"
    ],
    "created_owner_resource_id": 5
  },
  "actsAt": null,
  "owner": {
    "resourceId": 5,
    "name": "Popular Design Mill & Cabinet, Inc."
  }
}

GET /v1/api-keys/self/view

The resource your key acts at, as its default view shows it. Takes no parameters — the key names the resource (actsAt in GET /v1/api-keys/self); a key that acts at no resource gets 404 no_acting_resource. Your key must be able to read that resource; if it can, it can read the resource's default view.

The body is the resource's graph — the resource itself, the members and connected resources your key can read, the connections between them, a per-node summary, and the stored view's layout — plus one field:

Field Type Meaning
nodes array Every node drawn: the resource, its readable members, and resources connected to them. A node your key cannot read appears only as { id, readable: false }. Each readable node carries its status's statusAttention (none · watch · alert) and statusShape (circle · square · triangle · diamond, or null — then draw the shape for its attention: none → circle, watch → triangle, alert → diamond), and icon: its type's icon (see GET /v1/resources). Nodes are in list order: the resource itself first, then nodes whose status attention is alert, then watch, then the rest — within each group, members first (in the order the graph reads them), then outside nodes. List them as given.
interactions array The connections between drawn nodes. Field attachments (a value set on a field port) are not drawn as interactions — they appear as port settings on the node summary (summary.nodes[id].ports[].setting), and a value reachable only through one is not a node.
context object { resourceId, name, statusName, statusColorCode } for the resource itself.
alerts array The statuses that ask for attention among the resource's readable members — every member, including ones too many to draw: { statusName, colorCode, attention, shape, count, sampleResourceIds }, watch / alert only, alerts first. Empty when nothing needs attention. Under a type filter, only members the filter keeps count.
summary object Per-node detail for the drawn nodes (properties, derived keys, available transitions, ports). A type with a presentation profile carries types[id].profile = { icon, keyProperties }; a node of that type carries keys (just those properties' values). A node that inherits a field value from an enclosing item carries inherited = [{ kind: { resourceTypeId, typeName }, value, source, locked }] (for kinds it has no field of its own; value / source shaped like a port setting's). The icon to draw is the node's own icon.
overlay object Present when the resource has a default view: its stored positions and its settings. overlay.nodes holds a position row only for a node in nodes — never for a node the view hides or leaves out.
tray object { noInteractions, hidden }: how many of the resource's readable members have no interaction at all (including ones too many to draw; under a type filter, only kept types), and how many nodes the default view hides.
view object | null { name, settings } of the default view; null when the resource has none — then the plain graph is returned (not an error). settings = { defaultMode, statusColor, highlightTypeId, filterTypeIds, density, showLogic }.

The view's settings are already applied to the body: a type filter has removed the nodes it leaves out (counted in the resource node's rollup.filtered), and the nodes the view hides are left out with their connections (counted in tray.hidden; they still count in alerts, memberCount and the rollup). Each drawn member and outside node carries connected (whether it has any interaction). Highlight, status colour, density and showLogic (whether the view draws its logic layer) are for your renderer. A name your key cannot read is never sent: an unreadable type reads "a type".

Errors: 401 invalid_api_key · 404 no_acting_resource · the key-arm read refusals of GET /v1/resources/:id.

A key that acts at no resource:

From device-view-no-acting.json — status 404:

{
  "error": "no_acting_resource",
  "message": "This key does not act at a resource."
}

Resources — reading

GET /v1/resources

List resources of one type. Needs read scope for the type.

Parameter Type Default Notes
typeKey string — The type's code. Either this or typeId is required.
typeId string — The type's numeric id, as an alternative to typeKey.
statusId string — Restrict to one status.
q string — Case-insensitive substring match on name and identifier.
includeArchived boolean false Include archived resources.
ownerResourceId string — Accepted only if it equals your owner; otherwise 403.
limit string 50 Clamped to 1–100.
cursor string — From a previous nextCursor.
include string — Comma-separated. properties is the only member.
tagId string — Only resources carrying this tag. Repeat it (tagId=1&tagId=2) to require every one.
sort — — Not accepted on a key list: 400 sort_not_supported. A key list is always in resourceId order, so the cursor stays exact.

A parameter this door knows but cannot apply to a key (sort) is refused. A parameter it does not know at all is ignored — a misspelt limt=5 returns the default page, not a 400 — so check spelling against this table. A signed-in person reading the same door gets typeKey and include=properties with the same meaning (an unknown typeKey is 404 type_not_found there too).

Results include resources of types beneath the one you name, restricted to types you may read.

Response: { resources: [...], nextCursor: string | null }. Each row:

Field Type Meaning
resourceId string The id. A string — see conventions.
name string Display name.
identifier string | null Your own identifier, if one was set.
ownerResourceId number Always your organization.
resourceTypeId string The type's id.
resourceTypeName string The type's display name — not stable, do not key on it.
isSystem boolean Whether the type is built in.
createdAt string ISO-8601 UTC.
statusId number | null Current status, if the type has a lifecycle.
statusName string | null Display name of that status.
statusColor string | null Hex colour, for display.
archivedAt string | null Set if archived.
icon string The type's icon, always one of box · package · layers · hammer · wrench · ruler · truck · warehouse · factory · users · user · file-text · clipboard-list · tag · calendar · cog · circle-dollar-sign · door-open (lucide icon names). The nearest icon the type or one of its ancestor types declares, counting only types your key can read; box when there is none.
properties object Only with include=properties.
confirmedKeys object Only with include=properties. Keys somebody has confirmed.

Errors: 400 type_required, 400 invalid_type_id, 400 invalid_status_id, 400 invalid_owner_resource_id, 400 invalid_cursor, 400 invalid_include, 400 invalid_tag_id, 400 sort_not_supported, 403 type_not_readable, 403 owner_grant_required, 403 read_types_required, 403 owner_forced_to_grant, 404 type_not_found.

From list-operable-type.json — status 200:

{
  "resources": [
    {
      "resourceId": "962",
      "name": "Slice-1 — Kitchen 3 · Bid",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "176",
      "resourceTypeName": "Bid",
      "isSystem": false,
      "createdAt": "2026-08-02T17:41:46.784Z",
      "statusId": 282,
      "statusName": "Accepted",
      "statusColor": "#059669",
      "archivedAt": null,
      "icon": "box"
    },
    {
      "resourceId": "968",
      "name": "Slice-1 — Kitchen 3 · Bid 2",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "176",
      "resourceTypeName": "Bid",
      "isSystem": false,
      "createdAt": "2026-08-02T18:00:58.209Z",
      "statusId": 282,
      "statusName": "Accepted",
      "statusColor": "#059669",
      "archivedAt": null,
      "icon": "box"
    }
  ],
  "nextCursor": "968"
}

With include=properties:

From list-include-properties.json — status 200:

{
  "resources": [
    {
      "resourceId": "13806",
      "name": "Dealer",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "1264",
      "resourceTypeName": "Price Level",
      "isSystem": false,
      "createdAt": "2026-09-13T08:53:20.358Z",
      "statusId": null,
      "statusName": null,
      "statusColor": null,
      "archivedAt": null,
      "icon": "box",
      "properties": {
        "name": "Dealer",
        "pct_bp": 4536
      },
      "confirmedKeys": {}
    },
    {
      "resourceId": "13807",
      "name": "High-Volume Contractor",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "1264",
      "resourceTypeName": "Price Level",
      "isSystem": false,
      "createdAt": "2026-09-13T08:53:22.255Z",
      "statusId": null,
      "statusName": null,
      "statusColor": null,
      "archivedAt": null,
      "icon": "box",
      "properties": {
        "name": "High-Volume Contractor",
        "pct_bp": 5025
      },
      "confirmedKeys": {}
    },
    {
      "resourceId": "13808",
      "name": "Contractor",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "1264",
      "resourceTypeName": "Price Level",
      "isSystem": false,
      "createdAt": "2026-09-13T08:53:24.139Z",
      "statusId": null,
      "statusName": null,
      "statusColor": null,
      "archivedAt": null,
      "icon": "box",
      "properties": {
        "name": "Contractor",
        "pct_bp": 5678
      },
      "confirmedKeys": {}
    },
    {
      "resourceId": "13809",
      "name": "Homeowner",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "1264",
      "resourceTypeName": "Price Level",
      "isSystem": false,
      "createdAt": "2026-09-13T08:53:26.009Z",
      "statusId": null,
      "statusName": null,
      "statusColor": null,
      "archivedAt": null,
      "icon": "box",
      "properties": {
        "name": "Homeowner",
        "pct_bp": 6937
      },
      "confirmedKeys": {}
    },
    {
      "resourceId": "13810",
      "name": "Internal",
      "identifier": null,
      "ownerResourceId": 5,
      "resourceTypeId": "1264",
      "resourceTypeName": "Price Level",
      "isSystem": false,
      "createdAt": "2026-09-13T08:53:27.881Z",
      "statusId": null,
      "statusName": null,
      "statusColor": null,
      "archivedAt": null,
      "icon": "box",
      "properties": {
        "name": "Internal",
        "pct_bp": 0
      },
      "confirmedKeys": {}
    }
  ],
  "nextCursor": "13810"
}

An unknown include member is refused rather than ignored:

From list-include-invalid.json — status 400:

{
  "error": "invalid_include",
  "message": "unknown include member(s): foo (accepted: properties)"
}

GET /v1/resources/:id

One resource in full, including properties. Needs read scope.

Beyond the list-row fields (icon included), this returns properties, confirmedKeys, propertySchema (the shape the resource's type itself declares), declaredPropertySchema (that shape merged with every ancestor type's, a key declared nearer the resource's own type winning — every property the resource can carry), derivedKeys (properties computed by a formula — see conventions), derivedFrom / derivatives (provenance links), and edges. Anything here your key cannot read follows the names you cannot read rule (readable: false). readOnly is null, or { statusName } when the resource sits in a status its lifecycle declares read-only — its own fields, contents and outgoing connections then refuse writes (409 status_read_only).

Errors: 400 invalid_resource_id, 403 subject_outside_grant, 403 type_not_readable, 403 owner_grant_required, 404 resource_not_found (nothing has that id — checked after your access, so a 404 never hides a resource you could read).

From get-read-only-type.json — status 200:

{
  "resourceId": "13813",
  "resourceTypeId": "1179",
  "ownerResourceId": "5",
  "name": "MSDEV Customer 2",
  "identifier": null,
  "properties": {
    "kind": "contractor"
  },
  "statusId": null,
  "isVerified": false,
  "createdAt": "2026-09-13T17:58:40.818Z",
  "archivedAt": null,
  "derivedFromTypeId": null,
  "resourceTypeName": "Customer",
  "propertySchema": {
    "kind": {
      "type": "select",
      "label": "Kind",
      "options": [
        "dealer",
        "high_volume_contractor",
        "contractor",
        "homeowner",
        "internal"
      ]
    }
  },
  "declaredPropertySchema": {
    "kind": {
      "type": "select",
      "label": "Kind",
      "options": [
        "dealer",
        "high_volume_contractor",
        "contractor",
        "homeowner",
        "internal"
      ]
    }
  },
  "orgName": "Popular Design Mill & Cabinet, Inc.",
  "ownerOrgId": "1",
  "canvasMode": null,
  "statusName": null,
  "statusColorCode": null,
  "lifecycleId": null,
  "canvasId": null,
  "derivedFrom": [],
  "derivatives": [],
  "derivedKeys": [],
  "edges": [
    {
      "interactionId": 104,
      "typeName": "priced_at",
      "direction": "out",
      "otherResourceId": 13808,
      "otherResourceName": "Contractor",
      "thisResourcePortId": 80
    },
    {
      "interactionId": 105,
      "typeName": "bid_for",
      "direction": "in",
      "otherResourceId": 13416,
      "otherResourceName": "MSDEV Bid 2",
      "thisResourcePortId": 79
    }
  ],
  "confirmedKeys": [],
  "readOnly": null
}

A resource owned by another organization:

From get-outside-grant.json — status 403:

{
  "error": "subject_outside_grant"
}

GET /v1/resources/:id/events

The resource's history, newest first. Needs read scope. See events.md.

Parameter Type Default Notes
limit string 30 Clamped to 1–100. Note the default differs from the list.
cursor string — From a previous nextCursor.
filter string all all, changes, messages or quiet (see events.md). Anything else is treated as all.
after string — Poll for changes: oldest first, from a stored afterCursor / nextCursor, or start. See events.md.

Response: { items: [...], nextCursor: string | null, afterCursor: string }. With after, the order is oldest first and nextCursor is always present: it is where your next poll starts.

Errors: 400 invalid_resource_id, 400 invalid_cursor (an after this API did not issue), 403 subject_outside_grant, 403 type_not_readable, 403 owner_grant_required.

From events-outside-grant.json — status 403:

{
  "error": "subject_outside_grant"
}

GET /v1/resources/:id/members

Resources currently contained by this one. Needs read scope. Returns an array, one row per member: childResourceId, childResourceTypeId, childName, childTypeName, componentStatus, createdAt, roles, enrollmentCount and applicableActions.

From members-read.json — status 200:

[
  {
    "childResourceId": 24786,
    "childResourceTypeId": 3425,
    "childName": "Capture line",
    "childTypeName": "Bid Line",
    "componentStatus": "active",
    "createdAt": "2026-10-19T05:24:35.450Z",
    "roles": [],
    "enrollmentCount": 1,
    "applicableActions": []
  },
  {
    "childResourceId": 24789,
    "childResourceTypeId": 3425,
    "childName": "Capture line two",
    "childTypeName": "Bid Line",
    "componentStatus": "active",
    "createdAt": "2026-10-19T05:24:35.507Z",
    "roles": [],
    "enrollmentCount": 1,
    "applicableActions": []
  }
]

GET /v1/resources/:id/containers

The containers this resource is currently inside — the other direction from /members. Needs read scope on the resource. A container your key cannot read is left out, as if it were not there.

Response: { containers: [...] }. Each row: parentResourceId, name, resourceTypeId, resourceTypeName, statusName, statusColorCode, archivedAt, membershipCount (how many active memberships this resource holds in that container) and outboundFeeds (the destinations that container declares for moving what it holds on — only ones your key can read and a move would accept).

From containers-read.json — status 200:

{
  "containers": [
    {
      "parentResourceId": "24787",
      "name": "Capture set A",
      "resourceTypeId": "1171",
      "resourceTypeName": "Design Set",
      "statusName": "Draft",
      "statusColorCode": null,
      "archivedAt": null,
      "membershipCount": 1,
      "outboundFeeds": []
    }
  ]
}

Errors: 400 invalid_resource_id, 403 subject_outside_grant, 403 type_not_readable, 404 resource_not_found.

From containers-outside-grant.json — status 403:

{
  "error": "subject_outside_grant"
}

GET /v1/resources/search

Find resources by name across types — the read behind a picker. Needs read scope: only resources your key can read are returned.

Parameter Type Notes
q string Required in practice. Case-insensitive substring of the name. Fewer than 2 characters returns [].
types string Comma-separated type codes; types beneath each are included. all searches every type. Defaults to person,organization. A key never searches people: person is dropped without notice.
enrollInto string A container id. Leaves out exactly what POST /v1/resources/:id/members on that container would refuse (containment_cycle, containment_cap). Needs operate scope on the container, as the act does.
moveOf string A resource you are about to move. Leaves out the destinations a move would refuse: the resource itself, anything inside it, and containers whose cap it has reached. Needs operate scope on that resource.
moveFrom string With moveOf: the container it leaves, so the cap check is exact. Needs operate scope on it.
restrictToOrgId string A workspace filter for signed-in people. Ignored for a key — a key always searches its own readable set.

Response: a bare array (no envelope), at most 20 rows in all — however many types you ask for — ordered by name. Only ordinary resources are returned; the records ReSync keeps to describe a type or a rule never appear. Each row: resourceId, resourceTypeId, resourceTypeCode, resourceTypeName, name, ownerResourceId, icon.

Here only one of three matching lines may be added to the set: the other two are already inside a set, and a line's type allows one.

From search-enroll-into.json — status 200:

[
  {
    "resourceId": "24790",
    "resourceTypeId": "3425",
    "resourceTypeCode": "cab_line",
    "resourceTypeName": "Bid Line",
    "name": "Capture line loose",
    "ownerResourceId": "5",
    "icon": "box"
  }
]

Errors: 400 invalid_enroll_into, 400 invalid_move_of, 400 invalid_move_from, 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required.

From search-enroll-into-outside-grant.json — status 403:

{
  "error": "subject_outside_grant"
}

GET /v1/resources/:id/advancement

One call returning the resource, the status moves available to it, and the requirements for each. Needs read scope. Response: { resource, transitions, requirements }.

Note — not available to keys. GET /v1/resources/:id/why (a resource's status, key facts — each with its display label and value — recent status history, each available move with one plain-language sentence, and the explanation lines the ReSync app shows) is served to signed-in people only. A key gets 401 there. Build the same picture from /advancement and /events.

GET /v1/resources/:id/capabilities

The actions declared on this resource. Needs read scope. Response: { capabilities: [...] }.

GET /v1/resources/:id/requirements

The requirement checklist for one status move. Needs read scope.

Parameter Type Notes
transitionId string Required. From /advancement or /state-transitions.

Response: { requirements: [...] } — the same array a blocked status change returns, so one shape to learn. A full capture is requirements-read.json in the fixture set; the blocked status change is shown under PATCH /v1/resources/:id/status.

Each requirement carries leaves[], one entry per condition. Every leaf has a readable label (what the condition is) and a caption (where it stands for this resource, e.g. "missing", "present", "not recorded yet"). missing[] lists the labels of the unmet ones. A leaf with leaf: "subtree_compat" checks that everything inside the resource fits the settings passed down to it. It additionally carries violationCount, violations (the first 10: descendantResourceId, ancestorResourceId, kindTypeId, denyEdgeId, layer, code — compat_deny or contents_locked — and a readable message) and subtreeCompatVacuous (true when nothing inside receives a setting, so the check passes by default). An id is left out when your key cannot read that resource. The same fields appear in /advancement and in the 422 body of a blocked PATCH /v1/resources/:id/status.

Errors: 400 transitionId is required, 400 Invalid transitionId, 403 as above, 404 not found.


Resources — writing

PATCH /v1/resources/:id

Change a resource. Needs operate scope.

Field Type Notes
name string Non-empty. Ignored if the type computes its name from properties.
identifier string | null Your own identifier. Trimmed; empty becomes null.
properties object Merged into the existing map, not replaced.
actorResourceId number Optional. Record a specific person as the actor. Must be a person your key can read; otherwise 403 actor_not_readable.
actorAlias object Optional. { system, code } — record an external identity as the actor. Resolved under your organization; an identity seen for the first time is created there. The two actor fields are mutually exclusive.
presenceSessionId number Kiosk station credentials only. An integration key leaves it out.

The same three actor fields are accepted, with the same rules, by every key write that records an act: POST /v1/resources/:id/members, POST /v1/resources/:id/exit, POST /v1/resources/:id/move, POST /v1/resources/:id/attestations, PATCH /v1/resources/:id/status and POST /v1/resources/find-or-create. A malformed actor is refused with an English sentence in error (for example "actorResourceId and actorAlias are mutually exclusive"), not a code — see errors.md.

Omit both actor fields and the write is attributed to your key's own service identity, which is the usual case. See events.md.

Errors: 400 invalid_resource_id, 400 invalid_resource_update (a blank name), 400 derive_target_write_blocked (with keys), 409 confirmed_key_locked (with keys), 409 status_read_only (the resource sits in a read-only status, for example a sent bid), 409 unique_key_taken (another resource of your organization already holds that value of a declared unique key), 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 403 owner_grant_required, 403 actor_not_readable, 404 resource_not_found.

A resource in a read-only status:

From status-read-only-409.json — status 409:

{
  "error": "status_read_only",
  "message": "Harbor Homes — kitchen is Sent, a read-only status — its properties cannot be changed.",
  "resourceId": 24787,
  "statusId": 1314
}

A value a declared unique key already holds — here a modification given the identifier of one in the price book:

From unique-key-taken-409.json — status 409:

{
  "error": "unique_key_taken",
  "message": "NO BOTTOM is already used by another modification owned by Popular Design Mill & Cabinet, Inc.",
  "holderResourceId": "8890",
  "key": "properties.sku",
  "value": "NO BOTTOM"
}

From patch-producer-id.json — status 200:

{
  "resourceId": "14211",
  "resourceTypeId": "176",
  "ownerResourceId": "5",
  "name": "MSDEV Terms Verify (msdev:terms-verify-pos)",
  "identifier": null,
  "properties": {
    "name": "MSDEV Terms Verify (msdev:terms-verify-pos)",
    "recon_beat149": "p2"
  },
  "statusId": "280",
  "isVerified": false,
  "confirmedKeys": {},
  "createdAt": "2026-09-14T02:00:12.772Z"
}

A type you may read but not operate:

From patch-read-only-type.json — status 403:

{
  "error": "type_not_operable"
}

A property somebody has confirmed — note that the whole request is refused:

From patch-confirmed-locked.json — status 409:

{
  "error": "confirmed_key_locked",
  "message": "confirmed_key_locked: 'depth' is confirmed — a confirmed value is a human's assertion and cannot be overwritten by a key",
  "code": "confirmed_key_locked",
  "keys": [
    "depth"
  ]
}

POST /v1/resources/:id/members

Put a resource inside this one. Needs operate scope on both.

Field Type Notes
childResourceId number Required. Positive integer.
componentRoleId number Optional. Must belong to the container's type.
actorResourceId / actorAlias Optional, as for PATCH.

Returns 201 with the membership row:

From members-enrol.json — status 201:

{
  "componentId": 253767,
  "parentResourceId": 24787,
  "childResourceId": 24788,
  "componentRole": null,
  "sourceSlotId": null,
  "sourceRuleId": null,
  "componentStatus": "active",
  "statusChangedAt": "2026-10-20T04:44:03.377Z",
  "sortOrder": 0,
  "properties": {},
  "createdAt": "2026-10-20T04:44:03.377Z"
}

Errors: 400 child_resource_id_required, 400 invalid_component_role_id, 400 self_membership, 400 role_type_mismatch, 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 403 actor_not_readable, 404 role_not_found, 404 resource_not_found, 409 already_a_member, 409 status_read_only (the container sits in a read-only status), 422 compat_deny, 422 contents_locked, 422 containment_cycle, 422 containment_cap.

A container can pass a setting down to what it contains (for example a material chosen once for a whole set). Putting a resource inside such a container checks that resource, and everything inside it, against the settings it would now receive; a conflict is refused with 422 and nothing is written.

POST /v1/resources/:id/exit

Take this resource out of a container. Needs operate scope on both. The membership is marked exited, never deleted — the history stays.

Field Type Notes
containerId number Required. The container to leave.
actorResourceId / actorAlias Optional, as for PATCH.

Response: { exited: true, componentId: number }.

From exit.json — status 200:

{
  "exited": true,
  "componentId": 253768
}

Errors: 400 invalid_resource_id, 400 container_id_required, 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 403 actor_not_readable, 404 resource_not_found, 409 not_an_active_member, 409 status_read_only (the container sits in a read-only status).

POST /v1/resources/:id/move

Move this resource from one container to another in one step. Needs operate scope on the resource and on both containers.

Field Type Notes
fromContainerId number Required. The container it is in now.
toContainerId number Required. The container it goes to. Must differ from fromContainerId.
actorResourceId / actorAlias Optional, as for PATCH.

Both halves happen in one transaction: the old membership is exited (component.exited, carrying moved_to_resource_id) and the new one enrolled (component.enrolled, carrying moved_from_resource_id). Either both are recorded or neither is. The destination is checked exactly as an enrolment is (compat_deny, contents_locked, containment_cycle, containment_cap); a containment cap does not count the container being left. GET /v1/resources/search?moveOf= lists the destinations this door would accept.

Response: { moved: true, exitedComponentId, enrolledComponentId }.

From move.json — status 200:

{
  "moved": true,
  "exitedComponentId": 253767,
  "enrolledComponentId": 253769
}

Errors: 400 invalid_resource_id, 400 container_id_required, 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 404 resource_not_found, 409 already_a_member (the two containers are the same, or it is already in the destination), 409 not_an_active_member (it is not in fromContainerId), 409 status_read_only (either container sits in a read-only status), 403 actor_not_readable, 422 compat_deny, 422 contents_locked, 422 containment_cycle, 422 containment_cap.

From move-same-container.json — status 409:

{
  "error": "already_a_member",
  "message": "fromContainerId and toContainerId must differ"
}

POST /v1/resources/:id/attestations

Record that something happened. Needs operate scope. This is how a fact enters the ledger without changing the resource's own fields — inspected, sent, received.

Field Type Notes
eventType string Required. Must be a declared type — see GET /v1/attestation-event-types.
payload object Flat object of string/number/boolean values. No nesting, no arrays.
scopeResourceId number Optional. Record the event against a container as well. It must be a resource your key can read; otherwise 403 subject_outside_grant and nothing is recorded.
actorResourceId / actorAlias Optional, as for PATCH.

Returns 201 { eventUlid, eventType, resourceId, actorResourceId } — actorResourceId is your key's service identity unless the body named a person:

From attestation-create.json — status 201:

{
  "eventUlid": "01M5BSZAZ3SV3132XNPYTKV77X",
  "eventType": "bid.sent",
  "resourceId": 24787,
  "actorResourceId": 24784
}

One per status, unless declared repeatable. An event type is either repeatable (repeatable: true on GET /v1/attestation-event-types) or recorded once per resource for as long as the resource stays in its current status. A second one in the same status answers 409 attestation_not_repeatable; after the resource moves to another status (or back), it can be recorded again.

Errors: 400 invalid_resource_id; 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 403 actor_not_readable; 404 resource not found; 409 attestation_not_repeatable; 422 attestation_status_gate_unmet when the type requires the resource to be at a particular status first (requiredSubjectStatusId on the type). Both checks happen atomically with the write, and a refused attestation records nothing.

From attestation-409-not-repeatable.json — status 409:

{
  "error": "attestation_not_repeatable",
  "message": "Send bid is already recorded for Harbor Homes — kitchen in its current status.",
  "resourceId": 24787,
  "eventType": "bid.sent"
}

From attestation-422-gate-unmet.json — status 422:

{
  "error": "attestation_status_gate_unmet",
  "requiredSubjectStatusId": 280,
  "currentStatusId": 1314
}

The status check runs first: a non-repeatable type sent again after the resource has moved on answers 422, not 409. Its body-validation refusals (unknown eventType, non-flat payload, missing declared fields) return a sentence in error rather than a code — see errors.md.

From attestation-scope-outside-grant.json — status 403:

{
  "error": "subject_outside_grant"
}

PATCH /v1/resources/:id/status

Move a resource to a new status. Needs operate scope — a status move is an attest act, and your operate set grants it (like attestations).

Field Type Notes
toStatusId number Required. Must be a legal move from the current status (GET /v1/state-transitions).
actorResourceId / actorAlias Optional, as for PATCH.

Returns 200 { resourceId, fromStatusId, statusId } (plus advisories when a soft requirement is unmet):

From patch-status-200.json — status 200:

{
  "resourceId": 24789,
  "fromStatusId": 1318,
  "statusId": 1319
}

422 when a hard requirement is unmet. The body is the same requirements array as GET /v1/resources/:id/requirements — every requirement on the move, met or not — under a sentence in error:

From patch-status-422-requirements.json — status 422:

{
  "error": "Transition blocked by unmet requirements",
  "requirements": [
    {
      "requirementId": 2395,
      "name": "Contents compatible",
      "blocking": "hard",
      "satisfied": true,
      "missing": [],
      "leaves": [
        {
          "leaf": "subtree_compat",
          "label": "No design set settings conflicts",
          "caption": "nothing inside receives a setting yet",
          "satisfied": true,
          "attestable": false,
          "actionLabel": null,
          "actor": null,
          "actorRoles": [],
          "payloadFields": [],
          "blockedByActor": false,
          "satisfiedEver": false,
          "mayEmitUnvocab": false,
          "violations": [],
          "violationCount": 0,
          "subtreeCompatVacuous": true
        }
      ]
    },
    {
      "requirementId": 1528,
      "name": "R-SET-FREEZE — all lines frozen",
      "blocking": "hard",
      "satisfied": false,
      "missing": [
        "All lines frozen"
      ],
      "leaves": [
        {
          "leaf": "members",
          "label": "All lines frozen",
          "caption": "0/1 members at required status",
          "satisfied": false,
          "attestable": false,
          "actionLabel": null,
          "actor": null,
          "actorRoles": [],
          "payloadFields": [],
          "blockedByActor": false,
          "satisfiedEver": false,
          "mayEmitUnvocab": false,
          "memberTotal": 1,
          "memberSatisfied": 0
        }
      ]
    }
  ]
}

A move the lifecycle does not declare — here a frozen line back to Draft:

From patch-status-409-invalid-transition.json — status 409:

{
  "error": "Invalid status transition"
}

Errors: 403 subject_outside_grant, 403 type_not_operable, 403 operate_types_required, 403 actor_not_readable; 404 resource not found; 409 Resource is already in this status; 409 Invalid status transition; 422 requirements unmet. Like attestations, this door returns sentences in error rather than codes for its own refusals.

If another request moves the resource to the same status at the same moment, both succeed: the one that arrives second returns 200 with the same body and records nothing, so the status change is recorded once. If the other request moved it somewhere else, you get 409 Invalid status transition.


Ports and attachments

A resource connects to other resources through ports. A port that allows one connection and accepts one type is a field: it holds a single choice, for example the material a set is made of. The connection made through a field is an attachment. A field may also pass its choice down to the resources the holder contains (contentsScope: none, inherit — a contained resource may choose its own — or locked).

Attaching is checked against the compatibility rules: the new value against the holder, against what the holder is already connected to, against the choices the holder inherits, and — when the field passes down — against everything inside the holder that would receive it. A refusal names what conflicts in message.

Who may connect. You need write on the side whose port points out (the side making the choice) and only read on the side whose port points in (the shared value being chosen). Compatibility markings themselves (compatible_with / incompatible_with) are authored by signed-in people only.

GET /v1/resources/:id/ports

The resource's active ports. Needs read scope. Each port carries portName, direction, types (accepted types set on this port), effectiveTypes (what it actually accepts), maxConnections, defaultInteractionTypeId and contentsScope (from its definition; null for a port with none).

Every port also carries:

Optional ?workspace=<ownerResourceId> narrows typeChoices to global types and that owner's types. An owner outside your key's reach is 400 workspace_not_in_scope; a value that is not an id is 400 invalid_workspace.

From ports-read.json — status 200:

[
  {
    "resourcePortId": 1485,
    "resourceId": 13811,
    "portName": "Species",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-09-20T09:44:59.109Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 478,
    "types": [
      {
        "resourcePortTypeId": 396,
        "resourcePortId": 1485,
        "resourceTypeId": 1404,
        "typeName": "Species",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1404,
        "typeName": "Species"
      }
    ],
    "setting": {
      "value": {
        "resourceId": 7267,
        "name": "Laminate"
      },
      "scope": "own",
      "clear": {
        "edgeId": 1601,
        "label": "Clear"
      },
      "deviates": false,
      "locked": false,
      "source": null,
      "overrides": null,
      "conflicts": [],
      "note": null
    },
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 1503,
    "resourceId": 13811,
    "portName": "Front",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-09-20T10:07:33.257Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 480,
    "types": [
      {
        "resourcePortTypeId": 414,
        "resourcePortId": 1503,
        "resourceTypeId": 1406,
        "typeName": "Front",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1406,
        "typeName": "Front"
      }
    ],
    "setting": {
      "value": {
        "resourceId": 7369,
        "name": "Modern Horizon"
      },
      "scope": "own",
      "clear": {
        "edgeId": 2148,
        "label": "Clear"
      },
      "deviates": false,
      "locked": false,
      "source": null,
      "overrides": null,
      "conflicts": [],
      "note": null
    },
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2583,
    "resourceId": 13811,
    "portName": "Finish",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:22.749Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 854,
    "types": [
      {
        "resourcePortTypeId": 784,
        "resourcePortId": 2583,
        "resourceTypeId": 1405,
        "typeName": "Finish",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1405,
        "typeName": "Finish"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2658,
    "resourceId": 13811,
    "portName": "Sheen",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:23.552Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 857,
    "types": [
      {
        "resourcePortTypeId": 786,
        "resourcePortId": 2658,
        "resourceTypeId": 1411,
        "typeName": "Sheen",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1411,
        "typeName": "Sheen"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2665,
    "resourceId": 13811,
    "portName": "Interior",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:23.810Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 860,
    "types": [
      {
        "resourcePortTypeId": 788,
        "resourcePortId": 2665,
        "resourceTypeId": 1412,
        "typeName": "Interior",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1412,
        "typeName": "Interior"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2671,
    "resourceId": 13811,
    "portName": "Upper Door Style",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:24.072Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 863,
    "types": [
      {
        "resourcePortTypeId": 790,
        "resourcePortId": 2671,
        "resourceTypeId": 3427,
        "typeName": "Upper Door Style",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 3427,
        "typeName": "Upper Door Style"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2673,
    "resourceId": 13811,
    "portName": "Lower Door Style",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:24.251Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 866,
    "types": [
      {
        "resourcePortTypeId": 792,
        "resourcePortId": 2673,
        "resourceTypeId": 3428,
        "typeName": "Lower Door Style",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 3428,
        "typeName": "Lower Door Style"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2675,
    "resourceId": 13811,
    "portName": "Drawer",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:24.524Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 870,
    "types": [
      {
        "resourcePortTypeId": 794,
        "resourcePortId": 2675,
        "resourceTypeId": 1408,
        "typeName": "Drawer",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1408,
        "typeName": "Drawer"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2682,
    "resourceId": 13811,
    "portName": "Hinge",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:24.812Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 873,
    "types": [
      {
        "resourcePortTypeId": 796,
        "resourcePortId": 2682,
        "resourceTypeId": 1409,
        "typeName": "Hinge",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1409,
        "typeName": "Hinge"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  },
  {
    "resourcePortId": 2689,
    "resourceId": 13811,
    "portName": "Slide",
    "direction": "out",
    "description": null,
    "isActive": true,
    "createdAt": "2026-10-17T05:33:25.074Z",
    "scope": "boundary",
    "sortOrder": 0,
    "routingRule": {},
    "required": false,
    "expectedRoleId": null,
    "derivedFromPortDefinitionId": 876,
    "types": [
      {
        "resourcePortTypeId": 798,
        "resourcePortId": 2689,
        "resourceTypeId": 1410,
        "typeName": "Slide",
        "parameters": {}
      }
    ],
    "maxConnections": 1,
    "defaultInteractionTypeId": 135,
    "contentsScope": "inherit",
    "effectiveTypes": [
      {
        "resourceTypeId": 1410,
        "typeName": "Slide"
      }
    ],
    "setting": null,
    "allowedInteractionTypes": [
      {
        "id": 1,
        "name": "allocates"
      },
      {
        "id": 2,
        "name": "consumes"
      },
      {
        "id": 3,
        "name": "reserves"
      },
      {
        "id": 4,
        "name": "produces"
      },
      {
        "id": 5,
        "name": "depends_on"
      },
      {
        "id": 6,
        "name": "assigned_to"
      },
      {
        "id": 19,
        "name": "certified_in"
      },
      {
        "id": 20,
        "name": "licensed_for"
      },
      {
        "id": 21,
        "name": "derives_from"
      },
      {
        "id": 22,
        "name": "feeds"
      },
      {
        "id": 23,
        "name": "bid_for"
      },
      {
        "id": 24,
        "name": "priced_at"
      },
      {
        "id": 25,
        "name": "pays_on"
      },
      {
        "id": 40,
        "name": "mounts_on"
      },
      {
        "id": 135,
        "name": "uses"
      },
      {
        "id": 278,
        "name": "sells"
      }
    ],
    "isField": true,
    "typeChoices": []
  }
]

The capture above shows two fields holding their own values. For a field that inherits its value, the row's setting looks like this (illustrative; names are examples):

"setting": {
  "value": { "resourceId": 7267, "name": "Laminate" },
  "scope": "inherited",
  "locked": true,
  "source": { "resourceId": 13811, "name": "Kitchen design set" },
  "overrides": null,
  "conflicts": [],
  "note": "Laminate · locked by Kitchen design set",
  "clear": null,
  "deviates": false
}

The other note forms are "<value> · from <source>" (inherited, not locked), "Overrides <value> from <source>" (an own value replacing an inherited one), "Set here · same as <source>" (an own value equal to the inherited one it covers — kept so it stays put if the enclosing item changes), "Conflicts with <value>, locked by <source>" (an own value that differs from a lock above it) and "<value> from <source> · <value> from <source> — enclosing items disagree" (enclosing items pass down different values). The clear label reads "Clear — use <value> from <source>", "Clear — use <value>, locked by <source>" or "Clear" (nothing would apply).

Errors: 400 invalid_resource_id, 403 subject_outside_grant, 403 type_not_readable, 404 resource_not_found.

GET /v1/resources/:id/ports/:portId/legal-targets

For one port, which resources it could connect to now, and why not where it can't. Needs read scope on the resource (and on the context, for scope=context).

Parameter Type Notes
scope string type — every resource of the accepted types you can read; context (default) — the resources in contextId you can read.
contextId string Required for scope=context.
excludeInteractionId string Judge as if this existing connection on the port were gone (to change a choice).
q string Only candidates whose name or identifier contains this (case-insensitive). Either scope.
limit number Optional, 1–100. Return at most this many candidates, newest first (by creation, then id). Out of range is 400 invalid_limit.

Response: { sourcePort, candidates: [{ resourceId, name, resourceTypeId, verdict, reasons, viableTargetPorts }] }.

Without limit the list is complete, in no promised order for scope=type and in the context's own order for scope=context, and the response carries nothing else. With limit it also carries truncated (true when candidates were left out) and total (how many candidates there are in all, after q). Every candidate returned carries its own verdict either way. legal-targets-q.json in the fixture set shows q alone.

From legal-targets-limit.json — status 200:

{
  "sourcePort": {
    "portId": 6707,
    "portName": "product",
    "direction": "out",
    "maxConnections": 1,
    "held": 0,
    "capRemaining": 1
  },
  "candidates": [
    {
      "resourceId": 24699,
      "name": "WD Laminate Modern Vertical 30",
      "resourceTypeId": 191,
      "verdict": "legal",
      "reasons": [],
      "viableTargetPorts": [
        {
          "portId": 6623,
          "portName": "Sold as",
          "direction": "in"
        }
      ]
    },
    {
      "resourceId": 24697,
      "name": "WD Laminate Modern Vertical",
      "resourceTypeId": 191,
      "verdict": "legal",
      "reasons": [],
      "viableTargetPorts": [
        {
          "portId": 6622,
          "portName": "Sold as",
          "direction": "in"
        }
      ]
    },
    {
      "resourceId": 13812,
      "name": "MSDEV Line 2 W30-1/2*12",
      "resourceTypeId": 182,
      "verdict": "legal",
      "reasons": [],
      "viableTargetPorts": [
        {
          "portId": 6621,
          "portName": "Sold as",
          "direction": "in"
        }
      ]
    },
    {
      "resourceId": 12934,
      "name": "WOCM3",
      "resourceTypeId": 187,
      "verdict": "legal",
      "reasons": [],
      "viableTargetPorts": [
        {
          "portId": 3712,
          "portName": "Sold as",
          "direction": "in"
        }
      ]
    },
    {
      "resourceId": 12933,
      "name": "OCM3",
      "resourceTypeId": 187,
      "verdict": "legal",
      "reasons": [],
      "viableTargetPorts": [
        {
          "portId": 3711,
          "portName": "Sold as",
          "direction": "in"
        }
      ]
    }
  ],
  "truncated": true,
  "total": 3928
}

verdict is legal or illegal; each reason is { error, message } with the same codes a create would return (accepted_type, compat_deny, contents_locked, port_max_connections, port_inactive), plus the advisory no_viable_target_port. A full capture is legal-targets-type.json in the fixture set.

A port is listed in viableTargetPorts only when the connection faces the right way: the edge's source port sends (out or bidirectional) and its target port receives (in or bidirectional). An out port is the source of the connection it starts, an in port the target. A create that faces the wrong way is refused with 422 port_direction.

A connection whose source sits in a read-only status is never listed: the candidate is illegal with the reason status_read_only — the same code a create returns (409). When the anchor itself is the read-only source, every candidate carries it.

A compat_deny reason may also say what fired it:

Field Type Notes
denyEdgeId number | null The compatibility rule that refused the pair.
layer string Where that rule was found — on the values themselves or a default for their kinds.
firingNeighborId number | null When the refusal comes from something already attached to the anchor, that resource. null when the anchor and the candidate refuse each other directly, or when you cannot read the neighbour.
firingNeighborName string | null Its name, under the same rule (null with the id).
firingNeighborTypeName string | null Its type's name — null when you cannot read the type.

A refusal that comes from a setting further up or further down the containment may instead carry viaAncestorId, offendingResourceId, offenderCount, kindTypeId or lockEdgeId. Any of these ids is left out when you cannot read that resource; the message then says an enclosing item or an item inside.

Errors: 400 invalid_resource_id, 400 context_id_required, 400 invalid_context_id, 400 exclude_mismatch, 400 invalid_limit, 403 as above, 404 port_not_found.

Note — not available to keys. GET /v1/resources/:id/compat-candidates (the resources a person may mark compatible or incompatible with this one, each with the verdict and reasons a marking would get and a sentence for the pair's current state) is served to signed-in people only, like compatibility marking itself. A key gets 403 compat_authoring_session_only there.

POST /v1/resource-interactions

Make an attachment. Needs operate scope on the choosing side, read scope on the chosen side.

Field Type Notes
sourceResourceId number Required.
targetResourceId number Required.
interactionTypeId number Required. One of the source port's allowedInteractionTypes (see GET /v1/resources/:id/ports).
sourcePortId number Required. A port on the source.
targetPortId number Required. A port on the target.
replaceInteractionId number Optional. Swap an existing connection on the same source port for this one, atomically.

Returns 201 { interactionId, sourceResourceId, targetResourceId, interactionTypeId, contextId }.

From interaction-create.json — status 201:

{
  "interactionId": 2147,
  "sourceResourceId": 13811,
  "targetResourceId": 7369,
  "interactionTypeId": 135,
  "contextId": null
}

From interaction-refused-compat.json — status 422:

{
  "error": "compat_deny",
  "message": "Incompatible with Laminate (Species).",
  "denyEdgeId": 520,
  "layer": "value",
  "a": 7350,
  "b": 7267,
  "via": 13811
}

From interaction-not-operable.json — status 403:

{
  "error": "type_not_operable"
}

Errors: 400 source_resource_id_required, 400 target_resource_id_required, 400 interaction_type_id_required, 400 self_interaction, 400 ports_required, 400 port_not_found, 400 port_not_on_resource, 400 replace_mismatch, 403 subject_outside_grant, 403 type_not_operable, 403 type_not_readable, 403 compat_authoring_session_only, 409 status_read_only (the choosing side sits in a read-only status — a frozen line cannot change its product), 422 port_max_connections, 422 port_inactive, 422 accepted_type, 422 compat_deny, 422 contents_locked, 422 compat_type_not_connectable, 422 port_direction.

A connection type with no ports — derives_from, the link between a resource and the one it was made from — cannot be drawn here:

From interaction-400-ports-required.json — status 400:

{
  "error": "ports_required",
  "message": "sourcePortId and targetPortId are required"
}

Two requests at once. When two requests try to fill a port that takes one connection at the same moment, one succeeds and the other gets 422 port_max_connections. A replaceInteractionId that another request has already replaced gets 400 replace_mismatch.

DELETE /v1/resource-interactions/:id

Remove an attachment. Same scopes as creating it. Returns 204. Compatibility markings cannot be removed by a key. Deleting a connection that is already gone — including one another request deleted a moment earlier — returns 404 interaction_not_found, and only the deletion that happened is recorded.

Errors: 400 invalid_interaction_id, 403 as above, 403 compat_authoring_session_only, 404 interaction_not_found.


Creating and addressing resources

POST /v1/resources/find-or-create

Find a resource by an identifier from your system, or create it if it is not there. This is the idempotent way to bring your records into ReSync: call it again with the same key and you get the same resource back, not a second one.

Needs the type to be in find_or_create_types.

This body is snake_case, unlike everywhere else in the API. It predates the rest of this surface and has not been changed, because renaming the fields would break the integrations already using it.

Field Type Notes
type_key string Required. The type code to create.
external_system string Required. The name of an external system ReSync already knows — your administrator tells you which is yours. Identifiers are unique per system across every organization, so prefix yours.
external_key string Required. Your identifier for this thing.
properties object Optional. Applied at creation only — ignored if the resource already exists.
name string Optional. The new resource's name. A type that computes its names from properties ignores it; otherwise it wins over properties.name, and with neither the type's code is used. A blank name is 400 invalid_resource_update. Ignored if the resource already exists.
actorResourceId / actorAlias Optional, as for PATCH (these two keep their camelCase names). Without them the birth is attributed to your key's own service identity.

One step, all or nothing. The resource, your identifier for it and its resource.created event are written together. resource.created carries the properties it was created with and alias: { system, code } — your identifier — so its history starts complete; no separate property.changed follows. If two calls race on the same identifier, one creates and the other gets that same resource back (200). A new resource is then placed by any enrolment rules its type declares, as if a person had created it.

Created:

From foc-create.json — status 201:

{
  "resource_id": 24786,
  "created": true
}

Called again with the same identifier:

From foc-find.json — status 200:

{
  "resource_id": 24786,
  "created": false
}

What is refused — all as find_or_create_refused, with the reason in message:

From foc-refused-resync.json — status 400:

{
  "error": "find_or_create_refused",
  "message": "find-or-create is for external systems; 'resync' is the canonical system"
}

From foc-type-mismatch.json — status 409:

{
  "error": "find_or_create_refused",
  "message": "kpc-line-1 is already bound under pdmac-sql to a resource of another type"
}

Errors: 401 invalid_api_key, 400/403/409 find_or_create_refused, 400 invalid_resource_update (blank name), 403 owner_grant_required (a key with no owning organization), 403 actor_not_readable, 409 unique_key_taken (the new resource's properties would take a value a declared unique key already holds).

POST /v1/aliases/bind

Attach one of your identifiers to a resource that already exists. Also snake_case.

Field Type Notes
external_system string Required.
external_key string Required.
resource_id number Required.

The target must be a resource your key may change (it is in your organization and its type is in operate_types). Idempotent to the same resource; 409 if the identifier is already bound to a different one — that resource is not named.

Errors: 401 invalid_api_key, 403 subject_outside_grant, 400/404/409 as alias_bind_refused with the reason in message.

GET /v1/aliases/resolve

Look up the resource behind one of your identifiers. Read-only; never creates anything.

Parameter Type Notes
system string Required.
code string Required.

Response: { resourceId, resourceType, name } — resourceType is the type's display name, not its code (key on the code from GET /v1/resources/:id instead). Needs read scope on the resource it resolves to; an identifier bound to a resource your key cannot read answers exactly like one bound to nothing.

Errors: 401 invalid_api_key, 400 system_and_code_required, 400 unknown_external_system, 404 alias_not_found.

POST /v1/identity-claims/redeem

For a bridge — for example a messaging bot — that links an external account to a person in ReSync. The person creates a claim in the ReSync app and receives an 8-character code; they send it to your bridge, and your bridge redeems it, naming the external account. Key-only: a signed-in person is refused 403.

Field Type Notes
code string Required. The 8-character claim code.
actorAlias object Required. { system, code } — the external account being linked. It is found, or created under your organization the first time it is seen.

Needs bind_systems to include actorAlias.system — linking binds an identifier under that system, exactly as POST /v1/aliases/bind does. This is checked before the code is looked at, so a key without it learns nothing about pending claims.

Response: 200 { status: "linked", identityLinkId, stubResourceId, canonicalResourceId }, or 200 { status: "already_linked", ... } when that account is already linked to the same person.

From identity-claims-no-bind-systems.json — status 403:

{
  "error": "alias_bind_refused",
  "message": "key not scoped to bind under 'telegram'"
}

Errors: 401 invalid_api_key, 403 alias_bind_refused. Its other refusals put a sentence in error: 400 (no code, or no actorAlias), 404 (the code is unknown or expired), 409 (the account is already a verified account, is linked to someone else, or the person has a pending invite). Match on the HTTP status for those.


Reference data

These describe how the world is modelled. The lists are not type-scoped — any valid key may read them — and they change rarely, so cache them for the life of your process. A by-id read follows the list: GET /v1/resource-types/:id, GET /v1/lifecycles/:id, GET /v1/lifecycles/:id/graph and GET /v1/state-transitions?lifecycleId= answer 404 for anything your key's own list would not show, exactly as if it did not exist.

Endpoint Returns
GET /v1/resource-types Types available to your organization, each with its code and resourceTypeId. Optional lifecycleId filter. A full capture is resource-types-list.json in the fixture set.
GET /v1/resource-types/:id One type in full, including its declared property schema and containmentCaps — the limits it declares as a member ([{ containmentCapId, parentTypeId, maxParents }]: a resource of this type, or of a type under it, can be inside at most maxParents containers whose type is parentTypeId or a type under it). Captured in the quickstart (resource-type-by-id.json).
GET /v1/statuses Every status, with the lifecycle it belongs to.
GET /v1/lifecycles Every lifecycle.
GET /v1/lifecycles/:id One lifecycle with its statuses and transitions.
GET /v1/lifecycles/:id/graph One lifecycle as a graph in a single read: its statuses in display order (from the entry status outward, unreachable ones last), its transitions with their requirements and gate counts, the types that run it, and the consequence rules on those types — each rule listing the transitions whose required evidence is the event that triggers it. Every label is ready to display. lifecycle.can.author is always false for a key. Each status also carries legalTargets (the statuses a new transition from it may reach) and illegalTargets (every other status of the lifecycle, mapped to the sentence that says why not); both are empty unless the reader can author the lifecycle, so they are always empty for a key. Each transition carries capture (what it photographs when it fires — see resource.captured in events.md; null when nothing) only for a reader who can author the lifecycle, so never for a key.
GET /v1/state-transitions Legal moves. Needs fromStatusId plus resourceId or lifecycleId. With resourceId, your key must be able to read that resource — refusals as for GET /v1/resources/:id.
GET /v1/attestation-event-types The eventType values you may attest, with their declared fields. A full capture is attestation-event-types-read.json in the fixture set.
GET /v1/property-keys Property keys in use across types, with labels and value types.
GET /v1/units Units of measure.
GET /v1/interaction-types The kinds of relationship that can exist between resources. Each carries connectable: true when it may be used for a connection, false for compatibility types, which are set from a resource's compatibility sheet rather than as connections.

The legal moves for one of your resources:

From state-transitions-read.json — status 200:

{
  "transitions": [
    {
      "transitionId": 824,
      "fromStatusId": 1318,
      "toStatusId": 1319,
      "actionLabel": "Freeze Line",
      "autoAdvance": false,
      "toStatusName": "Frozen",
      "toStatusPreventsUsage": false,
      "toStatusIsAllocating": false,
      "toStatusSubjectReadOnly": true
    }
  ]
}

A key with no read or operate scope at all can still read these, but will be refused everywhere else — so if reference data works and nothing else does, your scopes are the thing to check.