Organizations, Clusters, and Locations

The ownership and access scopes that drive most public API workflows.

Spatial data is organized as organization-owned clusters with locations inside them. Access is assigned through organization permission groups or direct user permissions. Existing direct-user Contributor shares remain a read/write compatibility source for their exact cluster, but clients cannot create new legacy shares.

Cluster responses include display metadata for map and list UIs: color is a muted #RRGGBB background color, and useLightText tells clients whether light text should be used over that color. POST /v1/spatial/clusters and PATCH /v1/spatial/clusters/{clusterId} accept an optional color as #RGB or #RRGGBB; if a new cluster omits it, the API chooses from a generated muted color set to contrast with the owner's existing cluster colors.

Cluster lifecycle permissions are independent. Creating an organization-owned cluster requires final ClusterCreate=Allow in the organization, updating one requires final ClusterUpdate=Allow, and deleting one requires final ClusterDelete=Allow. New user-owned protected clusters are rejected. These decisions include the owning organization's exact entitlement ceiling and the current credential ceiling, so a broad organization role or narrower personal access token cannot bypass them.

Deleting Clusters and Locations

Delete a building through its location, or delete a cluster together with its locations:

DELETE /v1/spatial/locations/{locationId}
DELETE /v1/spatial/clusters/{clusterId}

These actions require final LocationDelete=Allow or ClusterDelete=Allow, respectively. The API checks the permission again inside the deletion transaction. Organization roles do not bypass credential ceilings or these capability decisions.

A successful response is 204 No Content. Resource records, dependent document and model records, and organization resource counts change in one transaction. Completed job and execution history is retained for audit. Location-scoped access ends when its location is removed; deleted inputs cannot be used to retry an old job.

Active jobs, uploads, processing work, surviving dependencies, or a concurrent change can prevent deletion. The API returns 409 Conflict with problem code spatial_resource_deletion_conflict. No partial database deletion is committed and object cleanup does not run for that failed transaction. Finish or cancel active work, then refresh the resource before trying again. A 403 remains a permission denial; a 503 indicates a service failure rather than missing delete authority.

Physical object cleanup runs asynchronously after the database commit. It uses the recorded exact object keys. Objects outside the deleted resource's storage namespace or still referenced elsewhere are retained for operational review; deletion does not sweep a shared folder or storage prefix. Search cleanup is also queued durably with the deletion, so a temporary background-service outage does not lose that work.

Editing Without Losing Concurrent Changes

Cluster and location responses include a revision string. Keep it with the snapshot displayed in your editor. Submit that value as baseRevision, together with only the fields the user changed:

PATCH /v1/spatial/locations/{locationId}
Content-Type: application/json

{
  "baseRevision": "12",
  "name": "North building",
  "description": null
}

Omitting a field preserves its current value. Explicit null clears a location's description, latitude, longitude or altitude, or a cluster's description. name, address and color cannot be null. Cluster edits accept name, description and color; location edits accept name, description, address, latitude, longitude and altitude. Empty updates, unknown fields, and missing or invalid revisions return 400. Treat revision strings as opaque tokens, not JavaScript numbers.

If another client changed a different field after your snapshot, the API merges your edit automatically and returns the current resource with a new revision. Store the complete returned editable state and its revision together. Moving a map marker should submit latitude and longitude together and omit altitude unless it was also edited.

If any submitted field changed, the entire update returns 409:

{
  "status": 409,
  "code": "resource_revision_conflict",
  "currentRevision": "14",
  "conflictingFields": ["name"]
}

Refresh the resource, show the user's unsaved values alongside the current values, and let the user review the conflict before submitting again. Never silently replace baseRevision and retry an overlapping edit. A future revision also returns this conflict code, with an empty field list. An accepted nonempty patch advances the revision even when a submitted value already matches.

Read responses contain resource metadata rather than nested persistence graphs. Use the dedicated locations, documents, bundles, annotations and permissions routes to load related content.

Cluster Images

A cluster can have one custom image for compact cards and the in-cluster sidebar hero. Upload a JPEG, PNG, or WebP source up to 25 MiB with the same presigned-upload pattern used by other assets:

POST /v1/spatial/clusters/{clusterId}/thumbnail/upload
PUT  {returned uploadUrl}
POST /v1/spatial/clusters/{clusterId}/thumbnail/complete

The start request declares fileName, contentType, and sizeBytes. Send the returned bucket and objectKey, the same declared content metadata, and the optional multipart completion fields to the complete route. Starting, completing, replacing, or deleting the image requires final ClusterUpdate=Allow and consumes the owning organization's Document storage pool.

Completion returns immediately with optimizationStatus set to processing. Background processing strips embedded image metadata and creates quality-82 WebP variants bounded to 256 px and 1024 px. Cluster list responses expose the 256 px result as nullable thumbnailUrl; cluster detail and update responses also expose the 1024 px heroImageUrl. Each URL has a matching expiry field, so clients should refresh the cluster response instead of persisting the URL.

Use the explicit route when a view needs to poll for readiness or choose a variant:

GET /v1/spatial/clusters/{clusterId}/thumbnail?size=small
GET /v1/spatial/clusters/{clusterId}/thumbnail?size=medium
DELETE /v1/spatial/clusters/{clusterId}/thumbnail

small is the card variant and medium is the default sidebar-hero variant. The read route requires final ClusterView=Read and returns HTTP 204 while no image is configured or optimization is still pending. Replacing or deleting the image removes the previous source and its derivatives.

Protected clusters, locations, documents, bundles, upload state, and jobs must all resolve to one organization owner. Startup and readiness fail closed if the database contains an unexpected user-owned protected row or a child whose owner does not match its parent; the service never silently reassigns that data.

Listing Across Clusters

Use the cluster list first when a client needs a picker:

GET /v1/spatial/clusters

The list includes only clusters where the current credential's final ClusterView=Read decision survives the organization entitlement ceiling and, for personal access tokens, the token's organization and capability ceilings. Authorization is applied before search, sorting, and pagination, so hidden matches do not shorten a page or change hasNextPage. Optional ownerType=Org and ownerId values further narrow this already-authorized set. Supplying ownerType=User returns 400 Bad Request.

The cluster detail route applies the same ClusterView=Read requirement. During the migration window, an active direct-user Contributor compatibility row can still supply the cluster-local read source without exposing the organization or another cluster; it does not supply cluster update or delete authority.

When the interface specifically needs clusters marked as shared across all organizations, use GET /v1/spatial/shared-clusters. It unions active shared-resource provenance and unmigrated Contributor compatibility candidates, rechecks final ClusterView=Read, deduplicates, and then paginates. Returned provenance and shared-guest flags are display metadata only; the accompanying effective capability values are the current evaluator result.

For read-only views that span more than one cluster, use the aggregate routes:

GET /v1/spatial/locations
GET /v1/spatial/documents
GET /v1/spatial/documents/types

Omit clusterIds to include all clusters the current user can read. Provide clusterIds as repeated query values or a comma-separated list to limit the response to selected clusters:

GET /v1/spatial/locations?clusterIds=11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222
GET /v1/spatial/documents?clusterIds=11111111-1111-1111-1111-111111111111

Location rows require both final ClusterView=Read for their parent and final LocationView=Read for the location from the same effective principal. The API applies those gates before search, sorting, and pagination, including on GET /v1/spatial/clusters/{clusterId}/locations. A location or descendant grant does not reveal a hidden cluster, and a visible cluster does not automatically reveal all of its locations. PAT organization scopes and capability maxima are preserved for both decisions.

The document aggregate route lists documents attached directly to clusters. It does not include documents attached to individual locations.

Location list and detail responses include annotationCount, so clients can show annotation badges without fetching each location's annotation list first.

POST /v1/spatial/clusters/{clusterId}/locations requires final LocationCreate=Allow for the parent cluster and a non-empty name. New locations are limited to organization-owned clusters. Updating a location requires final LocationUpdate=Allow; deleting one independently requires final LocationDelete=Allow. Create and patch requests accept optional latitude, longitude, and altitude: latitude must be finite and between -90 and 90, longitude must be finite and between -180 and 180, and altitude must be finite when provided. Known authorization failures use the same structured permission-denial response as cluster lifecycle operations.

Location Annotations

Annotations belong to locations and can represent a point, line, or polygon. New clients should send geometryType with coordinates; each coordinate is [longitude, latitude] or [longitude, latitude, altitude], and altitude can be null when altitudeMode is clampToSurface. The older position field remains the anchor point and is still accepted for point annotations.

Annotation reads include an opaque revision string. To edit, send that snapshot's baseRevision and only changed fields to PATCH /v1/spatial/annotations/{annotationId}. Omitted fields are preserved. Explicit null clears body, normal, priority, color, savedView or assigneeUserId; an empty documentIds array removes linked documents. Other fields cannot be null. Non-overlapping edits merge automatically. Geometry fields share one conflict group, and assignee/document links share another, so related edits cannot silently invalidate one another. A stale overlapping edit returns 409 with code: resource_revision_conflict; refresh, review the changes, and explicitly resubmit the revised draft. Never replace its revision and retry silently. Annotation ClearPriority/ClearColor/ClearSavedView/ClearAssignee flags are no longer accepted.

Annotations inherit the containing location's final, tier-capped LocationBundles result. Listing requires Read; creating, updating, and deleting require Write. There is no annotation-specific capability and no individual Bundle or annotation ACL. Personal access token organization scopes and capability maxima apply to every annotation route.

Annotation responses include workflow and display metadata: category, status, optional priority, optional hex color, optional savedView, authorUserId, optional assigneeUserId, and linked documents. Authorship is server-owned and comes from the authenticated creator.

Load the assignee picker from GET /v1/spatial/locations/{locationId}/annotations/assignable-users. Supply each proposed linked document as a repeated documentId query parameter. The caller and every returned candidate must have final LocationBundles=Read for that exact location plus final path-aware LocationDocuments=Read for every supplied document. Create and update use the same predicate, including the annotation's existing links when an update does not replace documentIds. Owning-organization membership is only a candidate source, not authority; shell-only members are omitted. Active direct-user legacy Contributor subjects remain candidates for their exact shared cluster until migration, but still pass the owning organization's current tier ceiling. Inaccessible users and grant sources are removed before search, sorting, paging, and profile-picture URL creation.

Use documentIds on create or update to link location documents, such as uploaded inspection images, to an annotation. Each proposed link separately requires final path-aware LocationDocuments=Read. The link is stored by document id, not by filename or folder path. Renaming a document or moving it to another folder keeps the annotation link intact, and deleting the document removes it from the annotation response.

Linked-document access is independent from annotation access. When the caller can read the annotation but cannot read a linked document at its current case-sensitive logical path, the API omits that document's ID and metadata from the annotation response without deleting the stored link. Document downloads and thumbnails continue to run their own document authorization checks; an annotation response never grants document content access.

{
  "title": "Facade crack",
  "geometryType": "point",
  "coordinates": [[10.5, 53.5, null]],
  "documentIds": [
    "44444444-4444-4444-4444-444444444444"
  ]
}

Annotation lists can be filtered with category, status, and updatedSince. category and status can be repeated or sent as comma-separated values, for example ?category=defect,hazard&status=open&status=in_progress. updatedSince is an inclusive ISO 8601 timestamp filter against updatedAt, falling back to createdAt for records that have not been updated.

Volume measurements are an additive opt-in annotation kind. Interactive users with the ordinary location and source permissions can deliberately choose Volume through secondary measurement-mode options, attach a structured definition to a polygon annotation, and request an immutable server calculation from a bounded triangle surface snapshot. Ordinary distance, area, VOB, report, and DXF measurement contracts are unchanged. Volume-owned annotations are omitted from ordinary authenticated annotation lists when the backend feature or actor eligibility is unavailable.

Location share links capture a separate bounded volume display snapshot. The public response can therefore include the footprint and then-current successful cut, fill, and net result even when authenticated volume authoring is disabled. That public projection is immutable and read-only: it provides no definition editing, recalculation, history polling, input, or evidence access. See Annotation volume measurements for the authoring workflow and limits.

On this page