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:
- Call with
limit(1–100). - Read
nextCursorfrom the response. - If it is a string, pass it back as
cursorfor the next page. - 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 gets401there. Build the same picture from/advancementand/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:
isField—trueon a field: a port that points out, takes one connection and accepts exactly one type (the kind of value it holds).falseon every other port. Use it to decide when to present a port as a single-choice value rather than a list of connections.allowedInteractionTypes—[{ id, name }], sorted by id: the interaction types a connection on this port may use. Offer exactly this list when you let someone pick a connection type; a type outside it is refused when you connect. Compatibility types (compatible_with/incompatible_with) are never in it.setting— on a field (a port that points out, takes one connection and accepts exactly one type): the value the field resolves to and where it comes from.nullon every other port, and on a field where nothing resolves. Shape:Field Meaning value{ resourceId, name }— the value to show for the field.scopeown(set on this resource) orinherited(set on an enclosing item and passed down).lockedtruewhen an enclosing item locks this setting for everything inside it.source{ resourceId, name }of the enclosing item the value or lock comes from;nullfor an own value with no lock above it.overrides{ value, source }— the inherited value an own value replaces, ornull.conflicts[{ value, source }]— when enclosing items pass down different values, every one of them; when an own value differs from a lock above it, the lock. Otherwise[].noteOne line of display text describing the above, or null. Show it as-is; do not build your own.clearOn a field holding its own value: { label, edgeId }.labelis display text naming what applies once the own value is removed (show it as-is); delete the connectionedgeId(DELETE /v1/resource-interactions/:id) to fall back to it.nullwhen the value is inherited.deviatestruewhen an own value differs from the inherited value it replaces (a deviation from what the enclosing item passes down);falseotherwise. An own value that differs from a lock above it is a conflict (conflicts), never a deviation.A resource your key cannot read is never named: its
resourceIdcomes backnull, itsnameis the word "a setting" (for a value) or "an enclosing item" (for a source), and the object carriesreadable: false;noteuses the same words.typeChoices—[{ resourceTypeId, name, icon }], sorted by name: the types that may still be added to this port's accepted types — readable to your key, accepted by the port's definition (a type it declares, or a subtype of one; any readable type when the port has no definition or its definition declares none), and not already on the port. Offer exactly this list; signed-in people adding a type outside it are refused.iconis the type's icon (seeGET /v1/resources).
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 gets403 compat_authoring_session_onlythere.
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:
403— the type is not in yourfind_or_create_types;400— the external system is not registered, or isresync(ReSync's own identifiers are not an external system), or the type is a built-in system type;409— the identifier is already bound to a resource of another type: the message says so and names no resource. Use a different identifier, or ask for the type you actually hold;409— the identifier is bound to a resource your key cannot read. That resource is not named — it does not exist for you, and a new one cannot take its identifier.
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.