{"openapi":"3.1.0","info":{"title":"Packregister Sync API","version":"1.0.0","description":"Read-only, versioned sync API for loading a workspace packaging register incrementally into a data warehouse via standard ELT tooling (Airbyte, Fivetran, Azure Data Factory, dlt).\n\nSync recipe per resource: (1) full sync — page through with `cursor` until `hasMore` is false, upserting by `id`, and store the final page’s `watermark`; (2) incremental — repeat with `updated_since` set to that stored `watermark` (inclusive; a few recent records may be re-delivered — upserts make that safe) and `include_deleted=true`, treating records with non-null `deletedAt` as deletions, then store the new final `watermark`. Always use the `watermark`, never the highest `updatedAt` you saw: `updatedAt` is stamped at write time while rows become visible at commit, so a self-computed watermark can permanently miss late-committing records. Soft deletes bump `updatedAt`, so tombstones surface in incremental runs.\n\nAuthenticate with a workspace API key, either as `Authorization: Bearer pr_live_…` or in the `x-api-key` header. Keys are workspace-bound: every response only ever contains data of the key’s own workspace."},"servers":[{"url":"/api/v1"}],"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"paths":{"/item-types":{"get":{"operationId":"listItemTypes","summary":"List item types","description":"Lists item types of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of item types.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ItemType"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/attributes":{"get":{"operationId":"listAttributes","summary":"List attributes","description":"Lists attributes of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of attributes.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Attribute"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createAttribute","summary":"Create a new attribute","description":"Requires a register:write-scoped key. Creates a new attribute (agent-mcp-connectivity) -- reverses issue #131's UI-only stance. options required (>=1) when type is select, forbidden otherwise. itemTypeId, when given, also links the new attribute to that item type.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeCreateRequest"}}}},"responses":{"200":{"description":"The attribute was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeCreateResponse"}}}},"400":{"description":"VALIDATION_ERROR: invalid JSON body, a missing/empty name, an invalid type, options missing for a select type or present for a non-select type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY, KEY_NOT_FOUND, KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks register:write (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_TYPE_NOT_FOUND: itemTypeId does not refer to a live item type in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/items":{"get":{"operationId":"listItems","summary":"List items","description":"Lists items of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones. Also supports the `q` point-lookup search parameter (issue #187) for finding one or a few items by name or article number — e.g. one recalled from a PDF — without syncing the full collection; see the Query parameter and docs/sync-api.md.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"},{"$ref":"#/components/parameters/Query"}],"responses":{"200":{"description":"One page of items.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Item"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createItem","summary":"Create an item by internal ids","description":"Requires a register:write-scoped key. Creates an item directly by internal ids (agent-mcp-connectivity) -- no external reference required, unlike PUT /items/external/{system}/{identifierType}/{externalId}. Always lands as status: draft (issue #185's safety net) -- see docs/sync-api.md.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemCreateRequest"}}}},"responses":{"200":{"description":"The item was created, as draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemCreateResponse"}}}},"400":{"description":"VALIDATION_ERROR: invalid JSON body, a missing/empty name or itemTypeId, or a bad attributeValues entry (same codes as PUT /items/{id}/attribute-values).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY, KEY_NOT_FOUND, KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks register:write (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_TYPE_NOT_FOUND: itemTypeId does not refer to a live item type in this workspace. ATTRIBUTE_NOT_FOUND / OPTION_NOT_FOUND: an attributeValues entry does not resolve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/item-attribute-values":{"get":{"operationId":"listItemAttributeValues","summary":"List item attribute values","description":"Lists item attribute values of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of item attribute values.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ItemAttributeValue"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/item-attribute-scoped-values":{"get":{"operationId":"listItemAttributeScopedValues","summary":"List item attribute scoped values","description":"Lists item attribute scoped values of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of item attribute scoped values.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ItemAttributeScopedValue"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/bom-lines":{"get":{"operationId":"listBomLines","summary":"List BOM lines","description":"Lists BOM lines of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of BOM lines.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BomLine"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/external-references":{"get":{"operationId":"listExternalReferences","summary":"List external references","description":"Lists external references of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of external references.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExternalReference"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/compliance":{"get":{"operationId":"listItemCompliances","summary":"List item compliance status","description":"Lists compliance items of the authenticated workspace, ordered strictly ascending on (updatedAt, id). Follow nextCursor until hasMore is false for a full or incremental sync; store the FINAL page’s watermark and pass it as updated_since (inclusive) on the next incremental run, with include_deleted=true to receive tombstones.","parameters":[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/UpdatedSince"},{"$ref":"#/components/parameters/IncludeDeleted"}],"responses":{"200":{"description":"One page of compliance items.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ItemCompliance"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; non-null exactly when hasMore is true."},"hasMore":{"type":"boolean"},"watermark":{"type":"string","format":"date-time","description":"The value to use as the next incremental run’s updated_since (take it from the final page, where hasMore is false). This is the server clock minus a grace window, clamped to the newest updatedAt in the page — NOT simply the newest updatedAt seen: updatedAt is stamped when a row is written but the row only becomes visible at commit, so syncing from the newest seen updatedAt can permanently miss a record that was stamped before your poll and committed after it. Using the watermark may re-deliver a few recent records (upserts make that safe) but never misses one."}},"required":["data","nextCursor","hasMore","watermark"]}}}},"400":{"description":"Invalid query parameter or cursor token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/item-types/{id}/attribute-schema":{"get":{"operationId":"getItemTypeAttributeSchema","summary":"Get the fillable attribute schema of an item type","description":"Returns, for one item type, the full \"fillable schema\" of its attributes in a single call (issue #188): each linked attribute's value type, unit, valid select options, and sub-attribute relations — everything a client (an LLM agent especially) needs to construct valid attribute values for this item type WITHOUT separately syncing GET /item-types, GET /attributes and reconstructing the parentAttributeId relation itself. This describes CURRENT LIVE CONFIGURATION, not a change-tracked history — unlike the seven GET list resources, there is no cursor/watermark/updated_since/include_deleted here; re-fetch when you need the latest state. Read this together with ItemTypeAttributeSchemaAttribute.subAttributes' description before writing an integration that touches sub-attributes: a sub-attribute here is scoped to its PARENT ATTRIBUTE, not to one specific option — which selected option a stored VALUE belongs to is a different resource, GET /item-attribute-scoped-values.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Internal Packregister item type id, as returned by the `id` field of GET /item-types (or the write endpoints below). Not an external reference — resolve one first via GET /item-types or PUT /item-types/external/{system}/{identifierType}/{externalId} if you only have a customer-side identifier."}],"responses":{"200":{"description":"The item type's full attribute schema.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemTypeAttributeSchemaResponse"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:read scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_TYPE_NOT_FOUND: id does not refer to a live (non-archived) item type in this workspace — including an id that belongs to a different workspace, which resolves identically to \"not found\" rather than leaking its existence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-key rate limit exceeded — code RATE_LIMITED or RATE_LIMIT_EXCEEDED for a denied rate-limit window (Retry-After carries the seconds until the window resets), USAGE_EXCEEDED for an exhausted usage quota (no Retry-After — apply a bounded client-side backoff instead of retrying immediately).","headers":{"Retry-After":{"description":"Seconds until the rate-limit window resets. Present on RATE_LIMITED/RATE_LIMIT_EXCEEDED when the remaining window is known; absent on USAGE_EXCEEDED.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/item-types/external/{system}/{identifierType}/{externalId}":{"put":{"operationId":"upsertItemTypeByExternalReference","summary":"Upsert an item type by external reference","description":"Requires a register:write-scoped key (see docs/sync-api-write.md). Resolves the item type linked to (system, identifierType, externalId) within the workspace — creating one if none exists yet (name and category then required in the body, unless id is given to link a specific existing item type instead), or partially updating the one already linked otherwise — and (re)links it. Idempotent: repeat calls with the same external reference never create a duplicate item type. attribute-to-item-type linking is out of scope for this endpoint (UI-only) — it is left untouched on both create and update.","parameters":[{"name":"system","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The external system, e.g. sap-erp."},{"name":"identifierType","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Sub-classification within that system, e.g. material-number."},{"name":"externalId","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The identifier in that system."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemTypeUpsertRequest"}}}},"responses":{"200":{"description":"The item type is now linked to this external reference — created is true only when a new item type row was inserted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemTypeUpsertResponse"}}}},"400":{"description":"VALIDATION_ERROR: an empty path segment, invalid JSON body, a body field failing validation, or (on a create with no id) a missing name/category.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_TYPE_NOT_FOUND: the id given in the body does not refer to a live item type in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"EXTERNAL_REFERENCE_CONFLICT: this (system, identifierType, externalId) is already linked to a different kind of entity (an item or an attribute, not an item type).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/items/external/{system}/{identifierType}/{externalId}":{"put":{"operationId":"upsertItemByExternalReference","summary":"Upsert an item by external reference","description":"Requires a register:write-scoped key (see docs/sync-api-write.md). Resolves the item linked to (system, identifierType, externalId) within the workspace — creating one if none exists yet (name and itemType then required in the body, unless id is given to link a specific existing item instead), or partially updating the one already linked otherwise — and (re)links it. itemType, when given, is resolved to an internal itemTypeId via a READ-ONLY lookup of that triple's own external reference — it never creates or modifies an item type (that is PUT /item-types/external/{system}/{identifierType}/{externalId}, issue #129). Idempotent: repeat calls with the same external reference never create a duplicate item. attributeValues (issue #131) lets attribute values be set purely via external references — attribute and (for select attributes) option resolved read-only via PUT /attributes/external/... and PUT /attribute-options/external/... (or a select option's own key); reconciled with the same core the UI uses. Omitted, attribute values are left untouched; an explicit empty array clears them. bomLines (issue #299) upserts this item's own BOM structure in the same call — components are resolved (and created, if given enough to create them) by their own external references; omitted, the current structure is left untouched; given, it replaces the lines this API created while never removing a manually-added one. See ItemUpsertRequest.bomLines and BomLinesSummary.","parameters":[{"name":"system","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The external system, e.g. sap-erp."},{"name":"identifierType","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Sub-classification within that system, e.g. serial-number."},{"name":"externalId","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The identifier in that system."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemUpsertRequest"}}}},"responses":{"200":{"description":"The item is now linked to this external reference — created is true only when a new item row was inserted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemUpsertResponse"}}}},"400":{"description":"VALIDATION_ERROR: an empty path segment, invalid JSON body, a body field failing validation, (on a create with no id) a missing name/itemType, an attributeValues entry whose value/options does not match its resolved attribute type, an attributeValues[].options entry submitting more than one option for an attribute whose allowMultiple is false, two attributeValues entries resolving to the same internal attribute, or two bomLines entries resolving to the same component. OPTION_ATTRIBUTE_MISMATCH: an attributeValues[].options[] entry resolves to an option that belongs to a different attribute than referenced. SUB_ATTRIBUTE_MISMATCH: a scopedValues[].attribute reference resolves, but isn’t configured as a sub-attribute of the attribute the chosen option belongs to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_NOT_FOUND: the id given in the body does not refer to a live item in this workspace. ITEM_TYPE_NOT_FOUND: itemType was given but does not resolve to a live item type in this workspace. ATTRIBUTE_NOT_FOUND: an attributeValues[].attribute (or a nested scopedValues[].attribute) reference does not resolve. OPTION_NOT_FOUND: an attributeValues[].options[] reference-or-key does not resolve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"EXTERNAL_REFERENCE_CONFLICT: this (system, identifierType, externalId) is already linked to a different kind of entity (an item type or an attribute, not an item). BOM_CYCLE_DETECTED: a bomLines component would make the BOM contain itself, directly or transitively.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/items/{id}/attribute-values":{"put":{"operationId":"setItemAttributeValues","summary":"Set attribute values on an existing item by internal id","description":"Requires a register:write-scoped key (see docs/sync-api-write.md). Sets attribute values on an ALREADY-EXISTING item, addressed by Packregister's own internal item id (id) — find one via GET /items?q=... (issue #187) — WITHOUT requiring the item or its attributes to be linked via external references first, unlike PUT /items/external/{system}/{identifierType}/{externalId} (built for ERP sync with stable pre-linked references). Never creates an item: id must already refer to a live one, or 404 ITEM_NOT_FOUND. attribute and (for select attributes) option are addressed by their own internal Packregister ids directly — no external reference resolution anywhere. attributeValues is a FULL desired-set write, not a merge: any attribute value not named in it is cleared — see ItemAttributeValuesRequest for the safe read-merge-PUT pattern for a single-field change. Every write through this path lands the item on status: 'draft' (issue #185), snapshotting its previous ready state before anything changes — never ready-direct like the external-reference sync-API paths, because this path carries no pre-linked external reference and no business assertion that the write is confirmed-correct.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Internal Packregister item id, as returned by GET /items (or found via its q point-lookup search, issue #187). Not an external reference."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemAttributeValuesRequest"}}}},"responses":{"200":{"description":"The item's attribute values were reconciled; the item is now draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemAttributeValuesResponse"}}}},"400":{"description":"VALIDATION_ERROR: invalid JSON body, a missing/empty attributeValues field, an attributeValues entry whose value/options does not match its resolved attribute type, an attributeValues[].options entry submitting more than one option for an attribute whose allowMultiple is false, or two attributeValues entries resolving to the same internal attribute. OPTION_ATTRIBUTE_MISMATCH: an attributeValues[].options[] entry resolves to an option that belongs to a different attribute than referenced. SUB_ATTRIBUTE_MISMATCH: a scopedValues[].attribute id resolves, but isn’t configured as a sub-attribute of the attribute the chosen option belongs to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ITEM_NOT_FOUND: id does not refer to a live item in this workspace. ATTRIBUTE_NOT_FOUND: an attributeValues[].attribute (or a nested scopedValues[].attribute) id does not resolve. OPTION_NOT_FOUND: an attributeValues[].options[].option id does not resolve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/attributes/external/{system}/{identifierType}/{externalId}":{"put":{"operationId":"linkAttributeByExternalReference","summary":"Link an external reference to an existing attribute","description":"Requires a register:write-scoped key (see docs/sync-api-write.md). Links (system, identifierType, externalId) to an ALREADY-EXISTING attribute, given its internal Packregister id in the body — attributes are UI-only, so there is no create-a-new-one branch (unlike the item-type/item upsert endpoints). Idempotent: repeat calls with the same external reference never create a duplicate reference row, and re-linking to a different attributeId simply repoints it (same full-row-overwrite contract as upsertApiExternalReference).","parameters":[{"name":"system","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The external system, e.g. sap-erp."},{"name":"identifierType","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Sub-classification within that system, e.g. attribute-code."},{"name":"externalId","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The identifier in that system."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeLinkRequest"}}}},"responses":{"200":{"description":"The attribute is now linked to this external reference.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeLinkResponse"}}}},"400":{"description":"VALIDATION_ERROR: an empty path segment, invalid JSON body, or a missing/empty attributeId.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ATTRIBUTE_NOT_FOUND: attributeId does not refer to a live attribute in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"EXTERNAL_REFERENCE_CONFLICT: this (system, identifierType, externalId) is already linked to a different kind of entity (an item, an item type, or an attribute option, not an attribute).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/attribute-options/external/{system}/{identifierType}/{externalId}":{"put":{"operationId":"linkAttributeOptionByExternalReference","summary":"Link an external reference to an existing attribute option","description":"Requires a register:write-scoped key (see docs/sync-api-write.md). Links (system, identifierType, externalId) to an ALREADY-EXISTING attribute option, given both its own internal Packregister id AND its parent attributeId in the body — options are UI-only, so there is no create-a-new-one branch. optionId must belong to attributeId, or the call fails with 400 OPTION_ATTRIBUTE_MISMATCH. Idempotent, same full-row-overwrite re-link contract as the attribute-linking endpoint above.","parameters":[{"name":"system","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The external system, e.g. sap-erp."},{"name":"identifierType","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Sub-classification within that system, e.g. option-code."},{"name":"externalId","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"The identifier in that system."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeOptionLinkRequest"}}}},"responses":{"200":{"description":"The option is now linked to this external reference.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttributeOptionLinkResponse"}}}},"400":{"description":"VALIDATION_ERROR: an empty path segment, invalid JSON body, or a missing/empty attributeId/optionId. OPTION_ATTRIBUTE_MISMATCH: optionId resolves, but to an option of a different attribute than attributeId.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"ATTRIBUTE_NOT_FOUND: attributeId does not refer to a live attribute in this workspace. OPTION_NOT_FOUND: optionId does not refer to a live option in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"EXTERNAL_REFERENCE_CONFLICT: this (system, identifierType, externalId) is already linked to a different kind of entity (an item, an item type, or an attribute, not an attribute option).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/items/bulk":{"post":{"operationId":"submitBulkItemUpsert","summary":"Submit a batch of items for asynchronous upsert","description":"Requires a register:write-scoped key. Accepts a list of items (issue #130), validates the request ENVELOPE synchronously only (malformed JSON; items missing/not-an-array/empty/oversized; each item's own structural shape) and creates NO job at all if that fails. Otherwise creates a tracking job and one pg-boss job per item, then responds 202 immediately — no item is resolved or written synchronously. Whether itemType resolves, or whether name is required for a given item, is NOT checked here (not knowable without a DB lookup) — see the per-item status on GET /items/bulk/{jobId} instead. Max items per request and full contract: docs/sync-api.md.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkItemsSubmitRequest"}}}},"responses":{"202":{"description":"Accepted — poll statusUrl for per-item outcomes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkItemsSubmitResponse"}}}},"400":{"description":"VALIDATION_ERROR: malformed JSON, items missing/not an array/empty/over the max batch size, or a per-item entry failing structural validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/items/bulk/{jobId}":{"get":{"operationId":"getBulkItemUpsertStatus","summary":"Status of a submitted bulk item upsert job","description":"Requires a register:write-scoped key. Aggregate status plus a per-item result list — never all-or-nothing. A failing item automatically retries with pg-boss backoff and surfaces as status retrying until either it succeeds or its retries are exhausted, at which point it becomes a terminal failed (dead-letter).","parameters":[{"name":"jobId","in":"path","required":true,"schema":{"type":"string","minLength":1}}],"responses":{"200":{"description":"Current status of the job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkItemsStatusResponse"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY (no key sent), KEY_NOT_FOUND (unknown or revoked), KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY (fallback).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks the register:write scope (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"BULK_JOB_NOT_FOUND: no bulk job with this id in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/files":{"post":{"operationId":"uploadFile","summary":"Upload a file for a file-type attribute value","description":"Requires a register:write-scoped key. Uploads a file (agent-mcp-connectivity) and returns the exact URL string a file-type attribute value expects. Same 32MB limit, any content type, as the session-based upload route this mirrors.","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]}}}},"responses":{"200":{"description":"The file was uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileUploadResponse"}}}},"400":{"description":"VALIDATION_ERROR: no file field present, or the file exceeds the 32MB limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, unknown, revoked, disabled or expired API key: MISSING_API_KEY, KEY_NOT_FOUND, KEY_EXPIRED, KEY_DISABLED, or INVALID_API_KEY.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Key lacks register:write (INSUFFICIENT_SCOPE) or the workspace is suspended (ORGANIZATION_SUSPENDED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Workspace API key as bearer token: `Authorization: Bearer pr_live_…`"},"apiKeyHeader":{"type":"apiKey","in":"header","name":"x-api-key","description":"Workspace API key in the x-api-key header."}},"parameters":{"Limit":{"name":"limit","in":"query","description":"Page size (default 100, max 1000).","schema":{"type":"integer","minimum":1,"maximum":1000,"default":100}},"Cursor":{"name":"cursor","in":"query","description":"Opaque keyset cursor from the previous page’s nextCursor. Returns records strictly after that position in (updatedAt, id) order — stable under concurrent mutations: pages never show gaps, and a record mutated mid-sync re-appears at the end with its new updatedAt (at-least-once delivery; upsert by id).","schema":{"type":"string"}},"UpdatedSince":{"name":"updated_since","in":"query","description":"ISO 8601 timestamp (1970-01-01 through 9999-12-31); only records with updatedAt >= this value are returned (INCLUSIVE). Pass the watermark of the previous run’s final page here — see the watermark response field. Offset-carrying timestamps are normalised to UTC.","schema":{"type":"string","format":"date-time"}},"IncludeDeleted":{"name":"include_deleted","in":"query","description":"When 'true', soft-deleted records (tombstones, deletedAt non-null) are included. Required for incremental syncs to observe deletions.","schema":{"type":"string","enum":["true","false"],"default":"false"}},"Query":{"name":"q","in":"query","description":"Point-lookup search term (issue #187), /items only: case-insensitive substring match against the item's own name and/or the externalId of any of its live external references (the closest thing this schema has to a first-class article/item number — see the \"External references as join keys\" section of docs/sync-api.md). Composes with the normal pagination/cursor/updated_since contract rather than replacing it. Not applied to item-type name or attribute values (a possible later, separate extension). Ignored (has no effect) on every other list endpoint.","schema":{"type":"string","minLength":1,"maxLength":200}}},"schemas":{"Error":{"type":"object","description":"Uniform error envelope for 400/401/403/429 responses.","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"Stable machine-readable code. 400: INVALID_QUERY, INVALID_CURSOR. 401: MISSING_API_KEY, KEY_NOT_FOUND, KEY_EXPIRED, KEY_DISABLED, INVALID_API_KEY. 403: INSUFFICIENT_SCOPE, ORGANIZATION_SUSPENDED. 429: RATE_LIMITED or RATE_LIMIT_EXCEEDED (denied rate-limit window, carries Retry-After), USAGE_EXCEEDED (exhausted usage quota, no Retry-After)."},"message":{"type":"string","description":"Human-readable detail (400 responses only)."}},"required":["code"]}},"required":["error"]},"OptionSummary":{"type":"object","description":"One choice of a select attribute. `key` is the stable identity from a sector package; null for hand-made options — join on `id` then.","properties":{"id":{"type":"string"},"key":{"type":["string","null"]},"label":{"type":"string"}},"required":["id","key","label"]},"EmbeddedExternalReference":{"type":"object","description":"External reference embedded on an entity — first-class keys into ERP/PLM systems. The flat /external-references endpoint is the authoritative sync source (own updated_at trail and tombstones); embedded lists are a live-rows convenience snapshot. The API guarantees NO uniqueness for hand-made references; package references (identifierType `package:*`) are the stablest keys.","properties":{"id":{"type":"string"},"system":{"type":"string","description":"External system identifier, e.g. 'sap-erp' or 'vrs'."},"identifierType":{"type":"string","description":"Sub-classification, e.g. 'sku' or 'package:attribute'. May be empty."},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]},"status":{"type":"string","enum":["active","deactivated"]}},"required":["id","system","identifierType","externalId","externalUrl","status"]},"ItemTypeUpsertRequest":{"type":"object","description":"When no item type is yet linked to this external reference: either id (link a specific, already-existing item type — see the id property) or both name and category (create a new one) must be given. Once an item type is linked (via either path, on this call or an earlier one), name/category are optional and only the fields present are changed (a partial update); omitting both is a valid no-op re-link, and id is then ignored — the existing link already determines the target. externalUrl, when present, is authoritative for the whole call (matches upsertApiExternalReference’s own full-row-overwrite contract) — omitting it clears any previously stored externalUrl.","properties":{"name":{"type":"string","minLength":1},"category":{"type":"string","minLength":1},"externalUrl":{"type":"string","format":"uri"},"id":{"type":"string","minLength":1,"description":"Internal Packregister item type id. Links this specific, already-existing item type (e.g. one created by hand in the UI, never previously linked to an external reference) instead of creating a duplicate. Only consulted when nothing is linked to this external reference yet — must refer to a live item type in the same workspace, or the call fails with 404 ITEM_TYPE_NOT_FOUND. Any name/category also present in the body are applied to it as a partial update. Ignored once an item type is already linked."}}},"ItemTypeUpsertResponse":{"type":"object","description":"The upserted item type and the external reference it is now linked to.","properties":{"id":{"type":"string","description":"Internal Packregister item type id."},"name":{"type":"string"},"category":{"type":"string"},"created":{"type":"boolean","description":"true only when this call inserted a brand-new item type row; false when it linked to or updated an existing one."},"external":{"type":"object","properties":{"system":{"type":"string"},"identifierType":{"type":"string","description":"The caller's own identifierType, unprefixed (the internal api: reservation is never exposed)."},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]}},"required":["system","identifierType","externalId","externalUrl"]}},"required":["id","name","category","created","external"]},"ItemTypeRef":{"type":"object","description":"The item type's own external reference triple, resolved (read-only — never created here) to an internal itemTypeId. Must already have been upserted via PUT /item-types/external/{system}/{identifierType}/{externalId} (issue #129), or otherwise carry an api:-prefixed external reference.","properties":{"system":{"type":"string","minLength":1},"identifierType":{"type":"string","minLength":1},"externalId":{"type":"string","minLength":1}},"required":["system","identifierType","externalId"]},"ExternalRef":{"type":"object","description":"A {system, identifierType, externalId} triple identifying an entity by its OWN external reference, resolved (read-only) to an internal id. Used for an attribute ref, a sub-attribute ref, and an option ref (issue #131) — same shape as ItemTypeRef, kept as its own schema since the entities it addresses differ.","properties":{"system":{"type":"string","minLength":1},"identifierType":{"type":"string","minLength":1},"externalId":{"type":"string","minLength":1}},"required":["system","identifierType","externalId"]},"OptionChoice":{"type":"object","description":"One chosen option of a select attribute (issue #131), addressed EITHER via the option's own external reference (external) OR its stable sector-package key (key) — exactly one of the two must be given. key is the same stable identity documented on OptionSummary (docs/sync-api.md's \"Scoped attribute values\" section); null/hand-made options have no key, so they can only be addressed via external. scopedValues carries sub-attribute values (issue #98) scoped to THIS specific chosen option — e.g. Gewicht/PIR entered per selected Materiaal option.","properties":{"external":{"$ref":"#/components/schemas/ExternalRef"},"key":{"type":"string","minLength":1},"scopedValues":{"type":"array","items":{"type":"object","properties":{"attribute":{"$ref":"#/components/schemas/ExternalRef"},"value":{"type":"string"}},"required":["attribute","value"]}}}},"AttributeValueEntry":{"type":"object","description":"One attribute value in ItemUpsertRequest.attributeValues (issue #131), keyed by the attribute's own external reference. value is the scalar for text/number/boolean/file attributes; options is the chosen option(s) for a select attribute. Which one is valid depends on the resolved attribute's type — sending the wrong one is 400 VALIDATION_ERROR (not knowable from this schema alone, since it depends on server-side attribute resolution).","properties":{"attribute":{"$ref":"#/components/schemas/ExternalRef"},"value":{"type":"string"},"options":{"type":"array","items":{"$ref":"#/components/schemas/OptionChoice"}}},"required":["attribute"]},"BomComponentInput":{"type":"object","description":"One BOM component in ItemUpsertRequest.bomLines (issue #299), keyed by the component item's own external reference. A bare externalReference links an already-existing item; adding name + itemType (and optionally attributeValues and externalUrl) also CREATES the component if nothing is linked to that reference yet — the same required-field rules as a top-level item upsert, resolved through the same core. A bare reference with nothing to resolve and not enough to create is 400 VALIDATION_ERROR.","properties":{"externalReference":{"$ref":"#/components/schemas/ExternalRef"},"name":{"type":"string","minLength":1},"itemType":{"$ref":"#/components/schemas/ItemTypeRef"},"attributeValues":{"type":"array","items":{"$ref":"#/components/schemas/AttributeValueEntry"},"description":"The component's own attribute values, same shape and same semantics as ItemUpsertRequest.attributeValues."},"externalUrl":{"type":"string","format":"uri","description":"Authoritative for the whole call, exactly like the top-level externalUrl — omitting it clears any previously stored externalUrl on this component's external reference."}},"required":["externalReference"]},"BomLineEntry":{"type":"object","description":"One line of the parent item's bill of materials (issue #299): which component, and how many of it.","properties":{"component":{"$ref":"#/components/schemas/BomComponentInput"},"quantity":{"type":"integer","minimum":1},"quantityDenominator":{"type":"integer","minimum":1,"default":1,"description":"Denominator of the exact rational quantity (issue #282): this line means quantity / quantityDenominator of the component per ONE parent. Omitted on a new line defaults to 1 (the whole-number case, unchanged from before this field existed); omitted on an already-existing line leaves its current denominator untouched. Multiply these along a BOM path as integers and round once at the end."},"tier":{"type":["string","null"],"enum":["primary","secondary","tertiary",null],"description":"Packaging tier of this component in this recipe. Omitted on a new line defaults to null; omitted on an already-existing line leaves its current tier untouched. An explicit null always clears it."}},"required":["component","quantity"]},"BomLinesSummary":{"type":"object","description":"The outcome of this call's bomLines reconciliation. Present on the response only when bomLines was given in the request.","properties":{"added":{"type":"integer","description":"Lines created by this call."},"updated":{"type":"integer","description":"Lines whose quantity, quantityDenominator or tier this call changed."},"removed":{"type":"integer","description":"API-created lines removed by this call because their component was absent from the payload."},"unchanged":{"type":"integer","description":"Lines whose component was in the payload but whose quantity, quantityDenominator and tier all already matched — no write happened."},"kept":{"type":"integer","description":"Manually-added (non-API-created) lines preserved despite being absent from the payload — the \"nothing was silently deleted\" guarantee, counted separately from unchanged so \"nothing changed\" is distinguishable from \"something of yours was protected\"."}}},"ItemUpsertRequest":{"type":"object","description":"When no item is yet linked to this external reference: either id (link a specific, already-existing item — see the id property) or both name and itemType (create a new one) must be given. Once an item is linked (via either path, on this call or an earlier one), name/itemType/id are optional and only the fields present are changed (a partial update); omitting itemType leaves the current itemTypeId unchanged. externalUrl, when present, is authoritative for the whole call (matches upsertApiExternalReference’s own full-row-overwrite contract) — omitting it clears any previously stored externalUrl. attributeValues (issue #131), when omitted, leaves the item’s current attribute values untouched; an explicit empty array reconciles to “no attribute values”.","properties":{"name":{"type":"string","minLength":1},"externalUrl":{"type":"string","format":"uri"},"itemType":{"$ref":"#/components/schemas/ItemTypeRef"},"id":{"type":"string","minLength":1,"description":"Internal Packregister item id. Links this specific, already-existing item (e.g. one created by hand in the UI, never previously linked to an external reference) instead of creating a duplicate. Only consulted when nothing is linked to this external reference yet — must refer to a live item in the same workspace, or the call fails with 404 ITEM_NOT_FOUND. Ignored once an item is already linked."},"attributeValues":{"type":"array","items":{"$ref":"#/components/schemas/AttributeValueEntry"},"description":"Attribute values addressed purely via external references — no Packregister-internal id required anywhere in this array. See AttributeValueEntry/OptionChoice."},"bomLines":{"type":"array","items":{"$ref":"#/components/schemas/BomLineEntry"},"description":"This item's bill-of-materials structure, as parent (issue #299). Omitted, the item's current BOM structure is left completely untouched. Given (including an explicit empty array), it is authoritative for the lines THIS API created: every component listed is upserted (line created, or its quantity updated), and any existing line whose component is absent from the array is removed — but only if it was itself created through this API. A manually-added line (created in the UI by a person) is never removed by a sync, no matter what the payload says."}}},"AttributeLinkRequest":{"type":"object","description":"Links this external reference to an ALREADY-EXISTING attribute (issue #131) — attributes are UI-only, so attributeId is always required; there is no create-a-new-one branch. externalUrl, when present, is authoritative for the whole call (same full-row-overwrite contract as the other write endpoints) — omitting it clears any previously stored externalUrl.","properties":{"attributeId":{"type":"string","minLength":1},"externalUrl":{"type":"string","format":"uri"}},"required":["attributeId"]},"AttributeLinkResponse":{"type":"object","description":"The linked attribute and the external reference it is now linked to.","properties":{"id":{"type":"string","description":"Internal Packregister attribute id."},"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"]},"created":{"type":"boolean","description":"true only when this call inserted a brand-new external-reference row; the attribute itself is never created by this endpoint."},"external":{"type":"object","properties":{"system":{"type":"string"},"identifierType":{"type":"string"},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]}},"required":["system","identifierType","externalId","externalUrl"]}},"required":["id","name","type","created","external"]},"AttributeOptionLinkRequest":{"type":"object","description":"Links this external reference to an ALREADY-EXISTING attribute option (issue #131) — options are UI-only, so both attributeId and optionId are always required. optionId must belong to attributeId, or the call fails with 400 OPTION_ATTRIBUTE_MISMATCH. externalUrl follows the same full-row-overwrite contract as every other write endpoint.","properties":{"attributeId":{"type":"string","minLength":1},"optionId":{"type":"string","minLength":1},"externalUrl":{"type":"string","format":"uri"}},"required":["attributeId","optionId"]},"AttributeOptionLinkResponse":{"type":"object","description":"The linked option and the external reference it is now linked to.","properties":{"id":{"type":"string","description":"Internal Packregister option id."},"attributeId":{"type":"string","description":"Internal Packregister attribute id."},"label":{"type":"string"},"created":{"type":"boolean","description":"true only when this call inserted a brand-new external-reference row; the option itself is never created by this endpoint."},"external":{"type":"object","properties":{"system":{"type":"string"},"identifierType":{"type":"string"},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]}},"required":["system","identifierType","externalId","externalUrl"]}},"required":["id","attributeId","label","created","external"]},"ItemUpsertResponse":{"type":"object","description":"The upserted item and the external reference it is now linked to.","properties":{"id":{"type":"string","description":"Internal Packregister item id."},"name":{"type":"string"},"itemTypeId":{"type":"string","description":"Internal Packregister item type id."},"created":{"type":"boolean","description":"true only when this call inserted a brand-new item row; false when it linked to or updated an existing one."},"external":{"type":"object","properties":{"system":{"type":"string"},"identifierType":{"type":"string","description":"The caller's own identifierType, unprefixed (the internal api: reservation is never exposed)."},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]}},"required":["system","identifierType","externalId","externalUrl"]},"bomLines":{"$ref":"#/components/schemas/BomLinesSummary"}},"required":["id","name","itemTypeId","created","external"]},"OptionChoiceInternal":{"type":"object","description":"One chosen option of a select attribute on PUT /items/{id}/attribute-values (issue #190), addressed by the option's own internal Packregister id — the internal-id sibling of OptionChoice, with no external/key ambiguity since there's only one way to address something internally. scopedValues carries sub-attribute values (issue #98) scoped to THIS specific chosen option.","properties":{"option":{"type":"string","minLength":1,"description":"Internal Packregister option id."},"scopedValues":{"type":"array","items":{"type":"object","properties":{"attribute":{"type":"string","minLength":1,"description":"Internal Packregister sub-attribute id."},"value":{"type":"string"}},"required":["attribute","value"]}}},"required":["option"]},"AttributeValueEntryInternal":{"type":"object","description":"One attribute value in ItemAttributeValuesRequest.attributeValues (issue #190), keyed by the attribute's own internal Packregister id — the internal-id sibling of AttributeValueEntry. value is the scalar for text/number/boolean/file attributes; options is the chosen option(s) for a select attribute. Which one is valid depends on the resolved attribute's type — sending the wrong one is 400 VALIDATION_ERROR.","properties":{"attribute":{"type":"string","minLength":1,"description":"Internal Packregister attribute id."},"value":{"type":"string"},"options":{"type":"array","items":{"$ref":"#/components/schemas/OptionChoiceInternal"}}},"required":["attribute"]},"ItemAttributeValuesRequest":{"type":"object","description":"Sets attribute values on an already-existing item via internal ids only (issue #190) — no external reference required anywhere. Required (not optional, unlike ItemUpsertRequest.attributeValues): setting attribute values is this endpoint's only job. THIS IS A FULL DESIRED-SET WRITE, NOT A MERGE: any attribute value the item currently has that is not named here is cleared, exactly like syncItemAttributeValues' contract on every other write path (this includes an explicit empty array, which clears every attribute value the item has, and still drafts the item if it was ready — see ItemAttributeValuesResponse). To change ONE field without clobbering the rest, read the item's current values first (GET /item-attribute-values, filtered client-side by itemId — that list has no server-side itemId filter), merge your one change into the full set, then PUT the complete result.","properties":{"attributeValues":{"type":"array","items":{"$ref":"#/components/schemas/AttributeValueEntryInternal"}}},"required":["attributeValues"]},"ItemAttributeValuesResponse":{"type":"object","description":"The item this write applied to. status is always 'draft' — issue #190's write path never leaves an item ready-direct, unlike the external-reference upsert endpoint's response, which has no status field at all because it never changes it.","properties":{"id":{"type":"string","description":"Internal Packregister item id."},"status":{"type":"string","enum":["draft"]}},"required":["id","status"]},"ItemCreateRequest":{"type":"object","description":"Creates an item directly by internal ids (agent-mcp-connectivity) -- no external reference required, unlike ItemUpsertRequest. Always lands as status: draft.","properties":{"name":{"type":"string","minLength":1},"itemTypeId":{"type":"string","minLength":1,"description":"Internal Packregister item type id."},"attributeValues":{"type":"array","items":{"$ref":"#/components/schemas/AttributeValueEntryInternal"},"description":"Optional. Same shape as ItemAttributeValuesRequest.attributeValues."}},"required":["name","itemTypeId"]},"ItemCreateResponse":{"type":"object","description":"The created item, always status: draft.","properties":{"id":{"type":"string","description":"Internal Packregister item id."},"name":{"type":"string"},"itemTypeId":{"type":"string"},"status":{"type":"string","enum":["draft"]},"created":{"type":"boolean","enum":[true]}},"required":["id","name","itemTypeId","status","created"]},"AttributeCreateRequest":{"type":"object","description":"Creates a new attribute (agent-mcp-connectivity) -- reverses issue #131's \"attributes are UI-only\" stance. options is required (>=1) when type is select, forbidden otherwise. itemTypeId, when given, also links the new attribute to that item type.","properties":{"name":{"type":"string","minLength":1},"type":{"type":"string","enum":["text","number","select","boolean","file"]},"unit":{"type":"string"},"category":{"type":"string","minLength":1},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","minLength":1}},"required":["label"]}},"itemTypeId":{"type":"string","minLength":1}},"required":["name","type"]},"AttributeCreateResponse":{"type":"object","description":"The created attribute.","properties":{"id":{"type":"string","description":"Internal Packregister attribute id."},"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"]},"unit":{"type":["string","null"]},"category":{"type":"string"},"itemTypeId":{"type":["string","null"],"description":"Set only when the create call linked it to an item type."}},"required":["id","name","type","unit","category","itemTypeId"]},"FileUploadResponse":{"type":"object","description":"The uploaded file. url is the exact string a file-type attribute value expects (the full /api/storage/{key} path, not a bare key).","properties":{"url":{"type":"string"},"file":{"type":"object","properties":{"name":{"type":"string"},"size":{"type":"number"},"type":{"type":"string"}},"required":["name","size","type"]}},"required":["url","file"]},"BulkItemEntry":{"type":"object","description":"One item in a POST /items/bulk request — the same fields as the single-item endpoint's path segments + body, folded into one object (no per-item URL).","properties":{"system":{"type":"string","minLength":1},"identifierType":{"type":"string","minLength":1},"externalId":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"externalUrl":{"type":"string","format":"uri"},"itemType":{"$ref":"#/components/schemas/ItemTypeRef"},"id":{"type":"string","minLength":1},"bomLines":{"type":"array","items":{"$ref":"#/components/schemas/BomLineEntry"},"description":"This item's bill-of-materials structure, as parent (issue #299) — same shape and semantics as ItemUpsertRequest.bomLines, applied by the same per-item job, with one exception: a component's own attributeValues are NOT supported here, matching this endpoint's existing no-attributeValues scope at the top level. A bomLines-driven failure (e.g. BOM_CYCLE_DETECTED) surfaces as that item's own per-item error on the status endpoint, never as a whole-submission rejection."}},"required":["system","identifierType","externalId"]},"BulkItemsSubmitRequest":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/BulkItemEntry"},"minItems":1}},"required":["items"]},"BulkItemsSubmitResponse":{"type":"object","description":"Accepted immediately — no item has been resolved or written yet.","properties":{"jobId":{"type":"string"},"statusUrl":{"type":"string","description":"Path of the GET /items/bulk/{jobId} status endpoint for this job."}},"required":["jobId","statusUrl"]},"BulkItemResult":{"type":"object","properties":{"external":{"type":"object","properties":{"system":{"type":"string"},"identifierType":{"type":"string"},"externalId":{"type":"string"}},"required":["system","identifierType","externalId"]},"status":{"type":"string","enum":["pending","ok","failed","retrying"],"description":"'pending': not started yet. 'ok': resolved and written. 'retrying': a failed attempt that pg-boss will still retry. 'failed': terminal — every retry attempt was exhausted (dead-letter)."},"itemId":{"type":["string","null"],"description":"Internal Packregister item id — set once status is ok, null otherwise."},"error":{"oneOf":[{"type":"object","properties":{"code":{"type":"string"},"message":{"type":["string","null"]}},"required":["code","message"]},{"type":"null"}],"description":"Present (non-null) once this item has failed at least one attempt."}},"required":["external","status","itemId","error"]},"BulkItemsStatusResponse":{"type":"object","properties":{"jobId":{"type":"string"},"status":{"type":"string","enum":["pending","running","done"],"description":"'pending': no item has started processing yet. 'running': at least one item is being (or has been) processed but not all are terminal. 'done': every item has reached a terminal state (ok or failed)."},"counts":{"type":"object","properties":{"total":{"type":"integer"},"ok":{"type":"integer"},"failed":{"type":"integer"},"retrying":{"type":"integer"},"pending":{"type":"integer"}},"required":["total","ok","failed","retrying","pending"]},"items":{"type":"array","items":{"$ref":"#/components/schemas/BulkItemResult"}}},"required":["jobId","status","counts","items"]},"ItemType":{"type":"object","description":"A packaging item type (e.g. Box, Pallet).","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"allowsBom":{"type":"boolean","description":"Whether items of this type can have a bill of materials. Presentation only — the API never rejects BOM writes based on it."},"tier":{"type":["string","null"],"enum":["primary","secondary","tertiary",null],"description":"Default packaging-tier hint for this item type (issue #308). Nullable — null means the type spans tiers. The authoritative tier for a specific recipe is on the BOM line, not here."},"externalReferences":{"type":"array","items":{"$ref":"#/components/schemas/EmbeddedExternalReference"}},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","name","category","allowsBom","tier","externalReferences","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"Attribute":{"type":"object","description":"An attribute definition, including its select options.","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"],"description":"Value type; determines how the raw text `value` of item-attribute-values is typed."},"category":{"type":"string"},"unit":{"type":["string","null"],"description":"Unit of measure, e.g. 'mm' or 'kg'."},"allowMultiple":{"type":"boolean","description":"Whether a select attribute allows multiple chosen options."},"options":{"type":"array","items":{"$ref":"#/components/schemas/OptionSummary"},"description":"Live options of a select attribute, in display order. Empty for other types."},"externalReferences":{"type":"array","items":{"$ref":"#/components/schemas/EmbeddedExternalReference"}},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","name","type","category","unit","allowMultiple","options","externalReferences","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"Item":{"type":"object","description":"A packaging item.","properties":{"id":{"type":"string"},"itemTypeId":{"type":"string"},"name":{"type":"string"},"allowsBom":{"type":["boolean","null"],"description":"Per-item override of the item type's allowsBom; null means it inherits. Presentation only."},"externalReferences":{"type":"array","items":{"$ref":"#/components/schemas/EmbeddedExternalReference"}},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","itemTypeId","name","allowsBom","externalReferences","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"ItemAttributeValue":{"type":"object","description":"One attribute value of one item. `value` is the raw stored text; the embedded `attribute` metadata makes client-side typing reproducible (authoritative source with its own change trail: /attributes).","properties":{"id":{"type":"string"},"itemId":{"type":"string"},"attributeId":{"type":"string"},"value":{"type":"string","description":"Raw text value: numbers in string form, booleans as 'true'/'false', file keys as text; empty string for select attributes (see selectedOptions)."},"selectedOptions":{"type":"array","items":{"$ref":"#/components/schemas/OptionSummary"},"description":"Chosen option(s) of a select attribute; empty for other types."},"attribute":{"oneOf":[{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"]},"unit":{"type":["string","null"]},"allowMultiple":{"type":"boolean"}},"required":["name","type","unit","allowMultiple"]},{"type":"null"}],"description":"Denormalized typing metadata of the referenced attribute. Deliberately without the attribute's full option list (it would repeat identically on every value record of the same attribute) — sync /attributes for option lists."},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","itemId","attributeId","value","selectedOptions","attribute","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"ItemAttributeScopedValue":{"type":"object","description":"One sub-attribute value scoped to a single selected option of a parent multiselect attribute (e.g. \"Gewicht (gr)\" or \"PIR\" per chosen \"PP\" option of \"Materiaal\"). A NEW, separate resource from /item-attribute-values — see that resource for the plain one-row-per-(item,attribute) case. `value` is the raw stored text, same conventions as item-attribute-values. The embedded `attribute` metadata describes the SUB-attribute (attributeId); `selection` identifies the chosen option of the PARENT attribute this row is scoped under.","properties":{"id":{"type":"string"},"itemId":{"type":"string"},"attributeId":{"type":"string","description":"The sub-attribute (e.g. \"Gewicht (gr)\"), not the parent multiselect attribute."},"value":{"type":"string","description":"Raw text value: numbers in string form, booleans as 'true'/'false'; empty string is a legitimate stored value here (not a select-attribute placeholder — scoped sub-attributes are never select type)."},"selection":{"oneOf":[{"$ref":"#/components/schemas/OptionSummary"},{"type":"null"}],"description":"The chosen option of the PARENT attribute this row is scoped to — join `selection.id` against an /attributes record's `options[].id`, or an /item-attribute-values record's `selectedOptions[].id`, to find the parent attribute and the specific item-attribute-value it was selected under. Deliberately just {id, key, label}, never the full parent option list — /attributes is the source of truth for that. Null only if the underlying selection could not be resolved."},"attribute":{"oneOf":[{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"]},"unit":{"type":["string","null"]},"allowMultiple":{"type":"boolean"}},"required":["name","type","unit","allowMultiple"]},{"type":"null"}],"description":"Denormalized typing metadata of the referenced sub-attribute (attributeId) — same shape and reasoning as the `attribute` embed on /item-attribute-values."},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","itemId","attributeId","value","selection","attribute","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"BomLine":{"type":"object","description":"A bill-of-materials line: component quantity within a parent item.","properties":{"id":{"type":"string"},"parentItemId":{"type":"string"},"componentItemId":{"type":"string"},"quantity":{"type":"integer"},"quantityDenominator":{"type":"integer","minimum":1,"default":1,"description":"Denominator of the exact quantity (issue #282): the line means quantity / quantityDenominator of the component per ONE parent. Multiply these along a BOM path as integers and round once at the end."},"tier":{"type":["string","null"],"enum":["primary","secondary","tertiary",null],"description":"Packaging tier of this component in this recipe. Nullable."},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","parentItemId","componentItemId","quantity","quantityDenominator","tier","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"ExternalReference":{"type":"object","description":"External reference as a flat join table. Exactly one of itemId/attributeId/itemTypeId/optionId is non-null. The API guarantees NO uniqueness for hand-made references; package references (identifierType `package:*`) are unique per workspace and the stablest keys. Cross-org workspace-link identity rows (reserved system `workspace`, issue #221) never appear on this API — reads filter them out and writes reject the reserved system.","properties":{"id":{"type":"string"},"itemId":{"type":["string","null"]},"attributeId":{"type":["string","null"]},"itemTypeId":{"type":["string","null"]},"optionId":{"type":["string","null"],"description":"An attribute-option id (issue #131) — join against the owning /attributes record’s options[].id."},"system":{"type":"string"},"identifierType":{"type":"string"},"externalId":{"type":"string"},"externalUrl":{"type":["string","null"]},"status":{"type":"string","enum":["active","deactivated"]},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","itemId","attributeId","itemTypeId","optionId","system","identifierType","externalId","externalUrl","status","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"ItemCompliance":{"type":"object","description":"Materialized compliance status (issue #189) of one item against one requirement profile — one row per (itemId, profileId). `compliant`/`missingAttributeIds` are the last COMPUTED result, kept as-is across a recompute trigger rather than cleared; `computedAt` is the freshness marker. `computedAt: null` means a recompute is pending (never computed yet, or invalidated by a relevant mutation) — treat `compliant`/`missingAttributeIds` as the last-known, not-yet-fresh value in that case, not as current. Because both halves of the compliance lifecycle (the dirty-mark that nulls `computedAt`, and the eventual recompute write) bump `updatedAt`, a single mutation can surface the same (item, profile) row twice within one incremental sync window — harmless under the standard upsert-by-id sync contract. Deliberately does NOT embed externalReferences: this is a per-(item,profile) row, so embedding them would duplicate the item’s reference array once per linked profile — join `itemId` to /items (which already embeds externalReferences[]) or to /external-references instead.","properties":{"id":{"type":"string"},"itemId":{"type":"string"},"profileId":{"type":"string"},"compliant":{"type":["boolean","null"],"description":"Last computed result; null only when never yet computed (computedAt is then also null)."},"missingAttributeIds":{"type":"array","description":"Open compliance issues: the profile requirements this (item, profile) pair currently fails. Deliberately left RAW (not resolved to names) — join attributeId against /attributes and /item-attribute-values, and optionId (when non-null) against the owning attribute’s options[].id, same convention as elsewhere in this API. Every entry carries all four fields: rows stored before issue #305 are normalized on read (absent kind defaults to \"missing\", absent ruleLabel to null), so the shape never varies across pre- and post-recompute rows.","items":{"type":"object","properties":{"attributeId":{"type":"string"},"optionId":{"type":["string","null"],"description":"Null for a top-level attribute issue; an attribute-option id for a per-selection sub-attribute issue (issues #98/#99)."},"kind":{"type":"string","enum":["missing","failed"],"description":"Issue #305. \"missing\" = the requirement has no value at all; \"failed\" = a value is present but does not satisfy the requirement’s rule (a threshold, a required answer, …). A requirement whose guard is not satisfied is NOT APPLICABLE and raises no issue at all, so it never appears in this array."},"ruleLabel":{"type":["string","null"],"description":"Issue #305. A short rendered form of the rule that failed, e.g. \"≤ 100 mg/kg\" or \"= Ja\". Always null on a \"missing\" issue, and can also be null on a genuine \"failed\" issue whose rule has no operand to render — never assume a non-null label just because kind is \"failed\"."}},"required":["attributeId","optionId","kind","ruleLabel"]}},"computedAt":{"type":["string","null"],"format":"date-time","description":"When compliant/missingAttributeIds were last (re)computed, ISO 8601 UTC with microsecond precision. Null means a recompute is pending — see this schema’s own description."},"item":{"oneOf":[{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]},{"type":"null"}],"description":"Denormalized identity embed of the item — /items remains the source of truth."},"profile":{"oneOf":[{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]},{"type":"null"}],"description":"Denormalized identity embed of the requirement profile."},"createdAt":{"type":"string","format":"date-time","description":"Creation time, ISO 8601 UTC with microsecond precision."},"updatedAt":{"type":"string","format":"date-time","description":"Last-change time, ISO 8601 UTC with microsecond precision. Bumped by every mutation, including soft deletion — the incremental sync cursor field."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"Soft-deletion time (tombstone marker), ISO 8601 UTC. Null for live records; records with a non-null deletedAt only appear when include_deleted=true."},"createdBy":{"type":"string","description":"Name snapshot of the creating actor ('system' for system writes)."},"updatedBy":{"type":"string","description":"Name snapshot of the last-changing actor."},"deletedBy":{"type":["string","null"],"description":"Name snapshot of the deleting actor; null for live records."}},"required":["id","itemId","profileId","compliant","missingAttributeIds","computedAt","item","profile","createdAt","updatedAt","deletedAt","createdBy","updatedBy","deletedBy"]},"ItemTypeAttributeSchemaRef":{"type":"object","description":"The item type this attribute schema is for — a minimal identity embed, not the full ItemType record (no audit set, no externalReferences: those are already available from GET /item-types if needed).","properties":{"id":{"type":"string","description":"Internal Packregister item type id (path parameter)."},"name":{"type":"string"},"category":{"type":"string"},"tier":{"type":["string","null"],"enum":["primary","secondary","tertiary",null],"description":"Default packaging-tier hint for this item type (issue #308), same field as ItemType.tier — kept in agreement so this endpoint and GET /item-types never disagree."}},"required":["id","name","category","tier"]},"ItemTypeAttributeSchemaSubAttribute":{"type":"object","description":"One SUB-attribute of a parent select attribute (e.g. \"Gewicht (gr)\" or \"PIR %\" under \"Materiaal\") — a value entered once per chosen option of its parent, not once per item. This object never carries its own `subAttributes` — a sub-attribute cannot itself be a parent (max one level deep, enforced by `attributes.parentAttributeId`).","properties":{"id":{"type":"string","description":"Internal Packregister attribute id of the SUB-attribute itself (not the parent)."},"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"],"description":"The value type this sub-attribute's own values must be entered as: 'text' — free text; 'number' — a numeric value (still transmitted as a string on the write API, but must parse as a number); 'select' — one of `options` below (or several, if `allowMultiple` is true); 'boolean' — 'true'/'false'; 'file' — an opaque file-key string. A client filling in a value for this attribute must branch on this field."},"category":{"type":"string","description":"Free-text grouping label for display purposes only (e.g. \"Materiaal\")."},"unit":{"type":["string","null"],"description":"Unit of measure, e.g. 'gr' or 'mm' — meaningful (non-null) only when type is 'number'; always null for every other type."},"sortCode":{"type":["string","null"],"description":"Optional VRS-style hierarchical sort code (e.g. \"4.01.01\") for display ordering only — never used to filter or join, and often null."},"allowMultiple":{"type":"boolean","description":"Whether more than one option may be chosen at once — meaningful only when type is 'select'; always false for every other type (sub-attributes are never select type in practice, but the field is still present for shape consistency with the parent attribute object)."},"options":{"type":"array","items":{"$ref":"#/components/schemas/OptionSummary"},"description":"Valid select choices, in display order — populated only when type is 'select'; an empty array for every other type. A client filling in a select value MUST choose one of these `id`s (or `key`s) — never invent or hallucinate an option that isn't listed here."}},"required":["id","name","type","category","unit","sortCode","allowMultiple","options"]},"ItemTypeAttributeSchemaAttribute":{"type":"object","description":"One TOP-LEVEL attribute linked to the item type. Same field meanings as ItemTypeAttributeSchemaSubAttribute, plus `subAttributes` — see that field's own description for the parent-vs-option-scoping nuance, the one thing a consumer of this endpoint most needs to get right.","properties":{"id":{"type":"string","description":"Internal Packregister attribute id."},"name":{"type":"string"},"type":{"type":"string","enum":["text","number","select","boolean","file"],"description":"The value type this attribute's own values must be entered as: 'text' — free text; 'number' — a numeric value (still transmitted as a string on the write API, but must parse as a number); 'select' — one of `options` below (or several, if `allowMultiple` is true); 'boolean' — 'true'/'false'; 'file' — an opaque file-key string. A client filling in a value for this attribute must branch on this field."},"category":{"type":"string","description":"Free-text grouping label for display purposes only (e.g. \"Afmetingen\")."},"unit":{"type":["string","null"],"description":"Unit of measure, e.g. 'mm' or 'kg' — meaningful (non-null) only when type is 'number'; always null for every other type."},"sortCode":{"type":["string","null"],"description":"Optional VRS-style hierarchical sort code (e.g. \"4.01.01\") for display ordering only — never used to filter or join, and often null."},"allowMultiple":{"type":"boolean","description":"Whether more than one option may be chosen at once — meaningful only when type is 'select'; always false for every other type."},"options":{"type":"array","items":{"$ref":"#/components/schemas/OptionSummary"},"description":"Valid select choices, in display order — populated only when type is 'select'; an empty array for every other type. A client filling in a select value MUST choose one of these `id`s (or `key`s) — never invent or hallucinate an option that isn't listed here."},"subAttributes":{"type":"array","items":{"$ref":"#/components/schemas/ItemTypeAttributeSchemaSubAttribute"},"description":"Sub-attributes of THIS attribute (issue #98) — e.g. \"Gewicht (gr)\" and \"PIR %\" under a \"Materiaal\" select attribute. Always empty for a non-select attribute, and may be empty for a select attribute with no sub-attributes configured. IMPORTANT SCOPING NUANCE: a sub-attribute here belongs to the PARENT ATTRIBUTE AS A WHOLE, not to any one specific option of it — the underlying relation (`attributes.parentAttributeId`) has no option dimension. So a sub-attribute in this array applies whenever ANY option of the parent select attribute is chosen; it is not pre-bound to one particular option. This is a SCHEMA (what attributes and sub-attributes exist, and what values are valid) — it never says which option a particular stored VALUE was entered under for a particular item. That per-selection binding is a separate concept and a separate resource: GET /item-attribute-scoped-values, where each row carries a `selection` field identifying exactly which chosen option of the parent it is scoped to. Concretely: this endpoint tells a client \"Materiaal has a sub-attribute Gewicht (gr), fill it in for whichever Materiaal option you pick\"; /item-attribute-scoped-values is where \"item X, Gewicht = 12, scoped to the PP option that was actually selected\" lives. Do not conflate the two — a client writing scoped attribute values (e.g. via the item-upsert write endpoint's `attributeValues[].options[].scopedValues`) still needs to know, when constructing that write, which option instance it is scoping the value to; this schema only tells it that such a sub-attribute relationship exists and what shape its value must take."}},"required":["id","name","type","category","unit","sortCode","allowMultiple","options","subAttributes"]},"ItemTypeAttributeSchemaResponse":{"type":"object","description":"The full, fillable schema of one item type: every top-level attribute linked to it, with type/unit/select-options/sub-attribute relations already resolved — the join a client would otherwise have to perform itself across GET /item-types, GET /attributes and the parentAttributeId relation. Attributes are ordered the same way the item type's own attribute links were created (`itemTypeAttributes.createdAt` ascending); each attribute's `subAttributes` are ordered by their own creation order; `options` arrays follow their existing display order (sortOrder, then createdAt).","properties":{"itemType":{"$ref":"#/components/schemas/ItemTypeAttributeSchemaRef"},"attributes":{"type":"array","items":{"$ref":"#/components/schemas/ItemTypeAttributeSchemaAttribute"},"description":"Top-level attributes only — a sub-attribute never appears at this level, only nested under its parent's own `subAttributes`. Empty array for an item type with no attributes linked (not an error)."}},"required":["itemType","attributes"]}}}}