Set Up Document Uploads

How to implement adaptive direct and resumable uploads for document libraries.

Document uploads use adaptive upload-start endpoints. The API decides whether the transfer should be a small direct upload or a durable resumable session:

  1. Call the matching upload-start endpoint with the file metadata and final document intent.
  2. If the response has mode: "direct", upload to the returned URL and call the matching upload-complete endpoint with the direct upload id and completion token.
  3. If the response has mode: "session", use /v1/upload-sessions/{sessionId} endpoints to request fresh upload URLs, report multipart parts, complete files, and complete the session.

Document records, collaboration-published versions, and their direct tickets or upload sessions must be organization-owned. Unexpected user-owned protected state blocks startup/readiness for investigation and is never silently reassigned.

Collaboration publishing rechecks the editor's current document write access, the organization's current document entitlement, the document scope and folder destination, and document storage capacity immediately before it attaches a new version. If any check changed while the draft was open, no version or publish-history record is created; refresh access or capacity and retry the same publish action.

Use the cluster, location, organization, or Project upload endpoints depending on where the document should appear:

POST /v1/document-libraries/cluster/{clusterId}/uploads
POST /v1/document-libraries/cluster/{clusterId}/uploads/batch
POST /v1/document-libraries/cluster/{clusterId}/uploads/complete
POST /v1/document-libraries/location/{locationId}/uploads
POST /v1/document-libraries/location/{locationId}/uploads/batch
POST /v1/document-libraries/location/{locationId}/uploads/complete
POST /v1/document-libraries/organization/{organizationId}/uploads
POST /v1/document-libraries/organization/{organizationId}/uploads/batch
POST /v1/document-libraries/organization/{organizationId}/uploads/complete
POST /v1/document-libraries/project/{projectId}/uploads
POST /v1/document-libraries/project/{projectId}/uploads/batch
POST /v1/document-libraries/project/{projectId}/uploads/complete

Project admission stores the exact Project identity and inherits LocationDocuments permissions at the destination's Project-relative path. Completing under a different Project is rejected even if both are readable. Session DTOs identify projectDocument and spatialProjectId. URL refresh, completion and receipt reads recheck current access and Project existence. Only the owning organization's existing Document pool is charged.

Upload Start

Send document metadata at upload-start. Completion is no longer the source of truth for bucket, object key, folder, document type, or custom metadata.

{
  "fileName": "site-a.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 482193,
  "checksumSha256": "f32e6d1775c8baf5b7d560a9304f7b34cfe1bbab2d9f8d3f8fd3db2cc7d6b2d0",
  "folderPath": "permits/2026",
  "documentType": "Permit",
  "customMetadata": {
    "trade": "electrical",
    "revision": 3
  },
  "preferResumable": false
}

customMetadata must be a JSON object and is limited to 16384 bytes after serialization. Omit it to preserve existing metadata when replacing a document, or send clearCustomMetadata: true to remove existing metadata. Do not send both in the same request.

Set preferResumable: true when a client wants resume behavior even for a small file.

For organization-owned documents, upload-start checks your final tier-capped Write access at the normalized target folder path, including any PAT ceiling, and the organization's effective Document-storage capacity. The declared bytes are reserved before the API returns a direct ticket or session, so concurrent uploads cannot all claim the same remaining capacity.

Upload Several Documents in One Session

Use the matching upload-batch-start endpoint when a user selects multiple documents and you want one resumable session instead of a separate start and final completion for every file. The request requires at least two files:

{
  "folderPath": "permits/2026",
  "documentType": "Permit",
  "customMetadata": {
    "trade": "electrical"
  },
  "files": [
    {
      "fileName": "site-a.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 482193,
      "checksumSha256": "f32e6d1775c8baf5b7d560a9304f7b34cfe1bbab2d9f8d3f8fd3db2cc7d6b2d0"
    },
    {
      "fileName": "site-b.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 391022
    }
  ]
}

folderPath, documentType, and customMetadata apply to every document in the selection. The response is always mode: "session" and reserves the sum of the declared file sizes. Transfer and acknowledge the session files through the shared session endpoints, then call:

POST /v1/upload-sessions/{sessionId}/complete

That final call reconciles remaining stored files, creates every document in one finalization transaction, and returns documentIds in session-file order. Repeating the completed session call replays the persisted ID list.

Direct Mode

Direct mode is optimized for small single-file uploads. It uses one API call to start, one object-storage PUT, and one API call to complete.

{
  "mode": "direct",
  "direct": {
    "directUploadId": "11111111-1111-1111-1111-111111111111",
    "completionToken": "server-issued-token",
    "bucket": "pixservice-dev",
    "objectKey": "user/.../documents/__direct-uploads/11111111-1111-1111-1111-111111111111/site-a.pdf",
    "uploadUrl": "https://storage.example.test/presigned-put",
    "expiresAt": "2026-07-07T12:15:00Z",
    "fileName": "site-a.pdf",
    "contentType": "application/pdf",
    "sizeBytes": 482193
  }
}

Upload bytes directly to direct.uploadUrl. Then call the scoped upload-complete endpoint with only the id and token:

{
  "directUploadId": "11111111-1111-1111-1111-111111111111",
  "completionToken": "server-issued-token"
}

The completion response includes status, directUploadId, and documentId when finalization succeeds. The API validates the ticket, token, route scope, object metadata, and stored upload intent. For organization-owned documents it also rechecks your current tier-capped path Write access and effective Document-storage capacity. Document attachment, reservation commit, and the completed ticket state are atomic; if either check changed, no document is attached and the reserved capacity is released for cleanup.

Only one direct-completion request owns the finalization fence. A concurrent request returns HTTP 200 with status: "finalizing", succeeded: false, and no error; retry the same scoped completion request until it becomes terminal. Finalization continues if the original HTTP request disconnects. The bounded finalization lease protects live work from cleanup and lets cleanup reclaim an abandoned finalizer after expiry. The winning response is currently the only direct response that carries documentId, so retain it.

Cancel an unfinished direct upload with:

POST /v1/uploads/direct/cancel

Only the user who created the direct upload can complete or cancel it. Creator cancellation does not require current Document Write, but is rejected after finalization acquires the fence. Atomic cancellation, expiry, and cleanup transitions prevent stale cleanup work from deleting an object after finalization wins the race.

Session Mode

Session mode is returned for large files, multipart files, explicitly resumable uploads, or any upload shape the server decides should not depend on one long-lived presigned URL.

{
  "mode": "session",
  "session": {
    "sessionId": "22222222-2222-2222-2222-222222222222",
    "status": "pending",
    "expiresAtUtc": "2026-07-08T12:00:00Z",
    "files": [
      {
        "fileId": "33333333-3333-3333-3333-333333333333",
        "fileName": "large.ifc",
        "isMultipart": true,
        "partSizeBytes": 67108864,
        "expectedPartCount": 1600,
        "parts": []
      }
    ]
  }
}

Inspect or resume a session:

GET /v1/upload-sessions/{sessionId}

Only the user who created the session can inspect, transfer, complete, or cancel it. For organization-owned documents, every fresh file, part, or batch upload-URL request rechecks current tier-capped Write access at the stored target path. If access changed, the session fails and its reserved capacity is released before the API denies the URL request.

For a non-multipart session file, request a fresh upload URL shortly before transfer:

POST /v1/upload-sessions/{sessionId}/files/{fileId}/upload-url

Upload the file to the returned URL, then complete the file:

POST /v1/upload-sessions/{sessionId}/files/{fileId}/complete

For a multipart file, request each part URL just in time:

POST /v1/upload-sessions/{sessionId}/files/{fileId}/parts/{partNumber}/upload-url

Upload exactly the returned byte range and send the file's declared contentType as the Content-Type header. The header is part of the storage signature and must match exactly.

After each successful part upload, report the part range and ETag:

{
  "eTag": "\"abc123\"",
  "offsetBytes": 402653184,
  "sizeBytes": 67108864
}

Then complete the file and session. The individual route remains useful for one file or a targeted retry:

POST /v1/upload-sessions/{sessionId}/files/{fileId}/complete
POST /v1/upload-sessions/{sessionId}/complete

File completion is idempotent after the file reaches uploaded or completed. If a response is lost, repeat the file-complete request to receive the persisted state without completing the object-storage upload again. Multipart retries also reconcile an exact-size stored object when object storage completed the upload before the API could persist the result. For organization-owned sessions, file completion rechecks current tier-capped Write at the stored target before completing object storage. Denial fails the session and releases its reservation, matching denied URL refresh and part reporting.

For a many-file transfer window, reconcile all uploaded objects with one bounded request:

POST /v1/upload-sessions/{sessionId}/files/complete-batch
{
  "fileIds": [
    "33333333-3333-3333-3333-333333333333",
    "44444444-4444-4444-4444-444444444444"
  ]
}

The response contains one result per id. Successful file state is retained and marked prepared after object metadata and exact size are reconciled, even when another item fails. Already uploaded or completed ids are idempotent. Session responses expose uploadedFileCount, preparedFileCount, and completedFileCount for progress UI. For large browser uploads, pipeline one bounded file-completion request behind the next bounded storage-transfer window. Server-side reconciliation then overlaps later PUTs without unbounded API or storage concurrency.

Session completion returns documentId when the document is finalized. Before finalization, it actively reconciles remaining files in bounded chunks, so a client can use the final session call as the authoritative completion boundary. It does not poll for storage transfers that are still running: missing objects, missing multipart parts, or size mismatches preserve other successful progress and reject finalization for a later retry.

For organization-owned documents the API checks current tier-capped path Write access and effective Document-storage capacity before atomically acquiring the finalization fence and queuing a durable BackgroundJobs task with the requester authorization snapshot. BackgroundJobs rechecks authorization and capacity before committing. The first completion call normally returns finalizing immediately. BackgroundJobs batch-writes artifacts, thumbnail tasks, and search tasks, then atomically commits attachment, reservation, and session completion. Denial or failure creates no document and releases the reservation.

Only one request owns finalization. Concurrent or replayed completion requests return HTTP 200 with both session.status and result.status set to finalizing, result.succeeded set to false, and no error. Repeat the same completion call until terminal. Completion responses use a compact session summary with aggregate counts and an empty files array; use GET /v1/upload-sessions/{sessionId} only for detailed per-file resume state. Completed retries replay the persisted documentId or documentIds. Finalization continues if the initiating HTTP request disconnects. Entering finalizing extends expiresAtUtc with a bounded server-managed lease, so cleanup cannot remove live finalization work but can reclaim a session if its finalizer crashes and the lease expires.

Detailed authoritative capacity is available from GET /v1/orgs/{organizationId}/usage only when the exact session or PAT actor has final OrganizationUsageView. Membership and legacy role labels are not enough. The response includes Document and Bundle storage plus current Cluster/Location and monthly/lifetime Job usage. The former GET /v1/orgs/{organizationId}/storage and GET /v1/users/me/storage owner-quota routes are hidden compatibility stubs that return HTTP 410.

URL refresh and part reporting are accepted only while the session and file are pending or uploading. Every accepted upload URL request, completed-part report, and file-completion request atomically moves a pending session to uploading and extends its sliding idle lease. The renewed expiresAtUtc always covers newly returned presigned URLs, preventing cleanup from reclaiming a slow but active upload. Reading session progress does not renew the lease. Expired sessions are marked expired at request time, and finalizing or terminal sessions and files that are already uploaded or terminal no longer receive fresh upload URLs.

For many small session files, request a bounded batch of fresh URLs:

POST /v1/upload-sessions/{sessionId}/upload-urls/batch
{
  "items": [
    { "fileId": "33333333-3333-3333-3333-333333333333" },
    { "fileId": "44444444-4444-4444-4444-444444444444", "partNumber": 7 }
  ]
}

Batch requests must contain at least one item, and the server enforces a configured maximum. Request URLs shortly before transfer; do not prefetch URLs for an entire long upload queue.

Cancel an unfinished session with:

POST /v1/upload-sessions/{sessionId}/cancel

A session cannot be canceled after it enters finalizing. Only its creator can cancel it, and current Document Write is not required for that access-reducing action.

Canceled, expired, denied, and failed state is cleaned by BackgroundJobs. Cleanup releases or expires organization Document-storage reservations idempotently, aborts incomplete multipart uploads, and deletes unfinalized uploaded objects.

Document Versions

Single-file upload-start accepts an optional previousVersionArtifactId to upload the file as a new version of an existing document in the same library scope. The parent must be writable by the caller at its own folder and must currently be the newest version of its lineage; a superseded parent returns 409 Document version conflict, while unknown, cross-scope and unauthorized parents all return 404 so the parent stays opaque. The new version always inherits the parent's folder — a declared folderPath is ignored — so versions stay co-located. The finalized document carries version and previousVersionId, and document lists keep serving only the newest version by default (versionFilter=newest).

GET /v1/spatial/documents/{documentId}/versions
POST /v1/spatial/documents/{documentId}/revert

The versions endpoint returns the full lineage newest-first plus headDocumentId. Revert restores an OLDER version as a new head by copying its bytes server-side into a new object (Document-storage capacity is reserved and committed for the copy); reverting the current head returns 409.

Folders And Listing

Read document metadata from:

GET /v1/spatial/documents/{documentId}
GET /v1/spatial/documents
GET /v1/document-libraries/cluster/{clusterId}/documents
GET /v1/document-libraries/location/{locationId}/documents
GET /v1/document-libraries/organization/{organizationId}/documents

Use GET /v1/spatial/documents without clusterIds for documents attached directly to all clusters the current user can read, or provide clusterIds as repeated query values or a comma-separated list for a selected-cluster document library.

GET /v1/document-libraries/organization/{organizationId}/documents is the direct organization library only; it does not include cluster or location documents owned by the same organization. Your current final tier-capped OrganizationDocuments value and the longest matching ordinal case-sensitive path rule are applied before search, sorting, paging, type values, folder results, or optional links are created. Metadata, download, and thumbnail requests recheck Read at the document's stored path. Deletion rechecks Write at that path.

Organization-owned cluster and location libraries use the same authorization order. Cluster documents apply final ClusterDocuments access at each stored path after organization defaults and cluster overrides. Location documents apply final LocationDocuments access after organization, cluster, and location overrides. The required discovery gates, entitlement cap, and PAT ceiling are included in the result. Unauthorized rows are removed before paging, type values, folder counts, and optional file or thumbnail links. The single-document metadata, download, thumbnail, move, and delete routes always recheck the current exact path.

Document list endpoints accept folderPath and recursive. Omit folderPath for all documents in the scope, send folderPath= for root-level documents, or send a path like folderPath=permits/2026 for one folder. Add recursive=true to include descendant folders.

Folders are logical document metadata. Moving a document or renaming a folder updates the document folderPath; it does not move or rename the underlying object-storage key. Folder and permission-path identity is case-sensitive, so A and a are different paths.

For an organization-owned document, PATCH /v1/spatial/documents/{documentId}/folder checks final tier- and credential-capped Write access at both the exact current folder path and the destination path. Source Write is the removal authority. If destination access is missing, the document and folder tree stay unchanged. An allowed move remains in the same Document storage pool, preserves the object key and charged bytes, and creates only missing destination-folder metadata, so the API evaluates zero additional capacity and does not reserve or charge the document again. Moving content out of a permission-managed folder does not unlock, rename, or move the managed root.

The API does not currently provide a Document copy or cross-library move endpoint. Creating an additional charged object requires a normal upload or publish path that reserves destination Document capacity before attachment.

If the organization's tier makes Documents unavailable, new upload URLs and document attachment are denied. Existing permission-managed folder bindings, logical folders, and their contents remain in place and become usable again when a compatible tier is restored.

Manage folder trees with:

GET /v1/document-libraries/cluster/{clusterId}/folders
POST /v1/document-libraries/cluster/{clusterId}/folders
PATCH /v1/document-libraries/cluster/{clusterId}/folders/{folderId}
DELETE /v1/document-libraries/cluster/{clusterId}/folders/{folderId}
GET /v1/document-libraries/location/{locationId}/folders
POST /v1/document-libraries/location/{locationId}/folders
PATCH /v1/document-libraries/location/{locationId}/folders/{folderId}
DELETE /v1/document-libraries/location/{locationId}/folders/{folderId}
GET /v1/document-libraries/organization/{organizationId}/folders
POST /v1/document-libraries/organization/{organizationId}/folders
PATCH /v1/document-libraries/organization/{organizationId}/folders/{folderId}
DELETE /v1/document-libraries/organization/{organizationId}/folders/{folderId}
PATCH /v1/spatial/documents/{documentId}/folder

Use parentPath on folder list endpoints to list one level below a parent folder. Creating or uploading into permits/2026 creates missing ancestor folders such as permits. Deleting a folder requires it to have no child folders and no documents.

For organization-owned organization, cluster, and location libraries, folder listing removes inaccessible paths before returning results and keeps only the ancestor names needed to reach an authorized descendant. Creating a folder requires the library's document Write capability at its new path plus the zero-byte Document-capacity prerequisite. Ordinary folder creation only adds logical folder metadata and cannot create, change, or adopt a permission-managed binding. Managed roots are materialized only by an authorized permission-rule mutation and its bounded, audited system reconciler. Renaming or moving a folder requires that same capability across every affected source and destination subtree path, and deletion requires Write at the empty folder path.

Download An Organization-Owned Document ZIP

Use the folder forms for one subtree:

GET /v1/document-libraries/organization/{organizationId}/documents/zip/status?folderPath=Board
GET /v1/document-libraries/organization/{organizationId}/documents/zip/download?folderPath=Board

Use the POST forms for all authorized direct organization documents or an explicit documentIds selection:

POST /v1/document-libraries/organization/{organizationId}/documents/zip/status
POST /v1/document-libraries/organization/{organizationId}/documents/zip/download

Cluster and location libraries use the same folder GET and all-or-selected POST shapes:

GET /v1/document-libraries/cluster/{clusterId}/documents/zip/status?folderPath=Board
POST /v1/document-libraries/cluster/{clusterId}/documents/zip/status
GET /v1/document-libraries/cluster/{clusterId}/documents/zip/download?folderPath=Board
POST /v1/document-libraries/cluster/{clusterId}/documents/zip/download
GET /v1/document-libraries/location/{locationId}/documents/zip/status?folderPath=Board
POST /v1/document-libraries/location/{locationId}/documents/zip/status
GET /v1/document-libraries/location/{locationId}/documents/zip/download?folderPath=Board
POST /v1/document-libraries/location/{locationId}/documents/zip/download

PixService filters the set with your current path-aware OrganizationDocuments=Read result before it calculates the ZIP fingerprint. The background request stores those exact authorized document IDs; it does not run a later whole-folder query that could add hidden or newly uploaded files. When your authorized set changes, status and download resolve a different snapshot rather than returning a previously prepared broader archive. Cluster and location archives use the same exact-ID snapshot rule with their path-aware ClusterDocuments=Read or LocationDocuments=Read result.

Location documents can be linked to spatial annotations with documentIds on the annotation create and update payloads. The annotation stores document ids, so folder moves and filename changes do not break the link.

Comments

Document comments are stored by PixService and use the same file permissions as the document. The exact current credential is evaluated against the document's current library and folder path. Listing requires final Document read access. Creating, editing, and deleting require final Document write access, including personal access token scope and capability ceilings, and only the author may edit or delete a comment.

GET /v1/spatial/documents/{documentId}/comments
POST /v1/spatial/documents/{documentId}/comments
PATCH /v1/spatial/documents/{documentId}/comments/{commentId}
DELETE /v1/spatial/documents/{documentId}/comments/{commentId}

On this page