Uploading Data to a Data Bundle
This guide explains how to upload the raw input files of a capture to a data bundle through the RealityConnect API.
All upload endpoints require the write:bundle scope; listing sessions requires read:bundle.
Upload flow
Section titled “Upload flow”A data bundle holds the raw inputs for a single capture: a point cloud, a trajectory, images, per-image metadata, and so on. The exact set of files a bundle accepts depends on the device the bundle was created for.
Files are uploaded in upload sessions. A session is a server-created container for one batch of a bundle’s files. When you open it you declare how many files the batch will contain (expectedFileCount); the session completes automatically once that many files have been finalized.
Each individual file is uploaded with an S3 multipart upload:
- Open a session on the bundle, declaring the total number of files you will send.
- For each file: initiate the upload, PUT its parts directly to the returned URLs, then finalize it.
- When the number of finalized files reaches
expectedFileCount, the session is markedcompleted.
sequenceDiagram
participant Client as Client
participant RCAPI as RCAPI
participant S3 as S3
Client->>RCAPI: POST upload-sessions
RCAPI-->>Client: sessionId + requirements
loop for each file
Client->>RCAPI: POST initiate file
RCAPI-->>Client: fileId + presigned part URLs
loop for each part
Client->>S3: PUT part
S3-->>Client: ETag
end
Client->>RCAPI: POST finalize
RCAPI-->>Client: file completed + sessionStatus
end
The upload requirements (returned when you open a session) tell you exactly which file types the bundle accepts, which are mandatory, and how they depend on each other.
The worked example: a ZF SLAM capture
Section titled “The worked example: a ZF SLAM capture”A ZF SLAM bundle is a good example because it accepts four different file types with a cross-type dependency. A complete ZF capture consists of:
| File | Type | Notes |
|---|---|---|
scan.las | point cloud | exactly one |
…laser_0.txt | trajectory | exactly one |
panorama/*.jpg | panorama images | many, each paired with metadata |
panorama/*.json | per-image metadata | many, one per image |
So a full ZF batch is 1 point cloud + 1 trajectory + N images + N metadata files. We will upload all of them in a single session.
Endpoints
Section titled “Endpoints”All paths are relative to your regional api_url. The bundle is addressed by its own id (bundleId).
| Method | Path | Scope | Purpose |
|---|---|---|---|
POST | /v1/bundles/{bundleId}/upload-sessions | write:bundle | Open a session |
GET | /v1/bundles/{bundleId}/upload-sessions | read:bundle | List sessions (resume / progress) |
POST | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files | write:bundle | Initiate a file upload |
GET | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/{startIndex} | write:bundle | Reissue expired part URLs |
POST | /v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalize | write:bundle | Finalize a file upload |
Before you begin
Section titled “Before you begin”In order to upload data to a data bundle, you need:
- a completed OAuth flow with an
access_tokenand your regionalapi_url - an existing data bundle (
bundleId)
If you do not have a bundle yet, create one first by following Creating a Data Bundle. That flow fetches the device input tree, picks a leaf dbuPath, and creates the bundle under a parent node.
Step 1: open an upload session
Section titled “Step 1: open an upload session”Declare the total number of files the batch will contain. For a full ZF capture with 13 images that is 1 + 1 + 13 + 13 = 28.
POST {api_url}/v1/bundles/{bundleId}/upload-sessionsAuthorization: Bearer {access_token}Content-Type: application/json
{ "expectedFileCount": 28 }The response returns the sessionId and the bundle’s requirements:
{ "sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b", "expectedFileCount": 28, "requirements": [ { "type": "pointclouds", "allowedExtensions": ["las", "laz"], "optional": false, "allowMultiples": false, "dependencies": [] }, { "type": "trajectories", "allowedExtensions": ["txt"], "optional": false, "allowMultiples": false, "dependencies": [] }, { "type": "pictures", "allowedExtensions": ["jpg", "png", "jpeg"], "optional": true, "allowMultiples": true, "dependencies": ["picturesMetadata"] }, { "type": "picturesMetadata", "allowedExtensions": ["json"], "optional": true, "allowMultiples": true, "dependencies": ["pictures"] } ]}Understanding requirements and cross-dependencies
Section titled “Understanding requirements and cross-dependencies”Each entry in requirements describes one file type the bundle accepts:
| Field | Meaning |
|---|---|
type | The identifier you pass as type when initiating a file upload. |
allowedExtensions | File extensions accepted for this type. |
optional | If false, the bundle cannot be processed without at least one file of this type. |
allowMultiples | If false, only one file of this type may be uploaded. |
dependencies | Other types that must also be present whenever this type is present. |
For the ZF example:
pointcloudsandtrajectoriesare mandatory (optional: false) and single (allowMultiples: false). Every ZF bundle needs exactly one of each.picturesandpicturesMetadataare optional and accept multiple files.- The two image types depend on each other:
pictures.dependencies = ["picturesMetadata"]andpicturesMetadata.dependencies = ["pictures"].
A dependency means: if a type is present, every type it lists must also be present. Because the ZF image types reference each other, they are all-or-nothing as a pair. You may upload the images and their metadata together, or skip both, but you cannot upload one without the other.
Valid ZF batches:
- Minimal —
1 pointcloud + 1 trajectory(no images).expectedFileCount = 2. - Full —
1 pointcloud + 1 trajectory + N pictures + N picturesMetadata.expectedFileCount = 2 + 2N.
Invalid:
- Images without their metadata (or vice versa) since the cross-dependency is unsatisfied.
- A batch with no point cloud or no trajectory since a mandatory type is missing.
Before you open a session, count every file you plan to upload and set that total as expectedFileCount. Optional types only count if you include them in the batch. The session completes when that many files are finalized, so the number you declare must match what you actually upload.
Step 2: upload each file
Section titled “Step 2: upload each file”Repeat the following three calls for every file in the batch.
2a. Initiate
Section titled “2a. Initiate”Pass the file name, its size in bytes, and its type (one of the requirements types):
POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/filesAuthorization: Bearer {access_token}Content-Type: application/json
{ "fileName": "scan.las", "fileSize": 734003200, "type": "pointclouds" }The response describes the multipart upload: how many parts to send and one presigned url per part.
{ "fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5", "parts": 8, "urls": [ { "partNumber": 1, "url": "https://s3…/part-1?…" }, { "partNumber": 2, "url": "https://s3…/part-2?…" } ]}2b. Upload the parts
Section titled “2b. Upload the parts”Split the file into parts sequential chunks of ceil(fileSize / parts) bytes and PUT each chunk to its presigned url. No auth header is sent on these requests; the URL is already signed. Keep the ETag response header of every part, you need it to finalize.
import math, requests
def upload_parts(file_path, file_size, urls): part_size = math.ceil(file_size / len(urls)) parts = [] with open(file_path, "rb") as handle: for entry in sorted(urls, key=lambda u: u["partNumber"]): res = requests.put(entry["url"], data=handle.read(part_size)) res.raise_for_status() parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]}) return partsPass the ETag back exactly as returned, including its surrounding quotes.
2c. Finalize
Section titled “2c. Finalize”Send the collected parts to complete the file. The endpoint returns 200 OK.
POST {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/finalizeAuthorization: Bearer {access_token}Content-Type: application/json
{ "parts": [ { "partNumber": 1, "etag": "\"a1b2…\"" }, { "partNumber": 2, "etag": "\"c3d4…\"" } ] }The response echoes the file and reports the session status:
{ "fileId": "c8c8cb0b-f476-4211-a3a4-72e4e04b91d5", "fileName": "scan.las", "type": "pointclouds", "size": 734003200, "status": "completed", "sessionStatus": "pending"}sessionStatus stays pending until the last expected file is finalized, at which point it becomes completed.
Refreshing expired URLs
Section titled “Refreshing expired URLs”Presigned URLs expire. If an upload runs long and a PUT starts returning 403, reissue the URLs from the part you stopped at and continue:
GET {api_url}/v1/bundles/{bundleId}/upload-sessions/{sessionId}/files/{fileId}/refresh/0Authorization: Bearer {access_token}{ "startIndex": 0, "urls": [ { "partNumber": 1, "url": "https://s3…/part-1?…" } ] }The file does not need to be re-initiated; only the part URLs are renewed.
Step 3: track progress and resume
Section titled “Step 3: track progress and resume”List a bundle’s sessions to check progress or resume an interrupted batch without keeping the sessionId in memory. The list is paginated and sortable, with an optional status filter.
Query parameters: page (default 1), limit (default 20, maximum 20), sortBy (createdAt or status, default createdAt), sortDir (ASC or DESC, default ASC), status (optional filter: pending or completed).
GET {api_url}/v1/bundles/{bundleId}/upload-sessions?page=1&limit=20&sortBy=createdAt&sortDir=DESCAuthorization: Bearer {access_token}{ "items": [ { "sessionId": "6ebf0fe5-4428-4b26-a7e4-e3274980123b", "status": "pending", "expectedFileCount": 28, "finalizedFileCount": 14 } ], "total": 1}finalizedFileCount versus expectedFileCount tells you how many files are left. To resume, initiate and finalize only the files that have not completed yet.
Validation and limits
Section titled “Validation and limits”Read this carefully as it determines what you are responsible for when using the RealityConnect API to upload data.
- The upload endpoints record each file under the
typeyou provide and complete the session purely on count: oncefinalizedFileCountequalsexpectedFileCount, the session iscompleted. At upload time, the server does not verify that thetypeis one of therequirements, that the file extension is allowed, that mandatory types are present, or that cross-dependencies are satisfied. - All the rules (mandatory types,
allowMultiples, and thedependenciesbetween types) are enforced after the bundle is sent for processing. A batch that ignores therequirementswill upload successfully but fail to process. - Deep content validation is never performed by the upload API. Whether a
.lasis a genuine ZF point cloud, or a.jsoncorrectly references its image, is only checked at processing.
Treat requirements as the contract. Honor the types, extensions, optionality, multiplicity, and dependencies in your upload batch so the bundle is processable once uploaded. Processing failures caused by an incorrect upload structure are not refundable.
Full example
Section titled “Full example”Putting it together for the ZF capture. files is the list you build from your local dataset, each entry pairing a path with its type from requirements.
import math, requests
API_URL = "https://<your-regional-api-url>"TOKEN = "<access_token>"BUNDLE_ID = "<bundleId>"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# (local path, schema type) for every file in the batchfiles = [ ("scan.las", "pointclouds"), ("2024-07-scan.laser_laser_0.txt", "trajectories"), ("panorama/2024-07-scan-idx40.jpg", "pictures"), ("panorama/2024-07-scan-idx40.json", "picturesMetadata"), # … remaining image / metadata pairs …]
def upload_one(session_id, path, file_type): size = __import__("os").path.getsize(path) base = f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions/{session_id}/files"
initiated = requests.post( base, headers=HEADERS, json={"fileName": path.split("/")[-1], "fileSize": size, "type": file_type}, ).json()
part_size = math.ceil(size / initiated["parts"]) parts = [] with open(path, "rb") as handle: for entry in sorted(initiated["urls"], key=lambda u: u["partNumber"]): res = requests.put(entry["url"], data=handle.read(part_size)) res.raise_for_status() parts.append({"partNumber": entry["partNumber"], "etag": res.headers["ETag"]})
return requests.post( f"{base}/{initiated['fileId']}/finalize", headers=HEADERS, json={"parts": parts}, ).json()
# 1. Open the session for the exact number of files we will send.session = requests.post( f"{API_URL}/v1/bundles/{BUNDLE_ID}/upload-sessions", headers=HEADERS, json={"expectedFileCount": len(files)},).json()
# 2. Upload every file under its type.result = Nonefor path, file_type in files: result = upload_one(session["sessionId"], path, file_type)
# 3. The last finalize reports the session as completed.assert result["sessionStatus"] == "completed"Next step
Section titled “Next step”Once every file is uploaded and the session is completed, continue to Processing and Monitoring to turn the raw inputs into viewable outputs.