> ## Documentation Index
> Fetch the complete documentation index at: https://docs.track3d.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Capture API Setup Guide

> Sign in, download structure sheets, check for design changes, and upload captures to Track3D using the External Capture API.

<Note>
  Base URL for all API calls: `https://gateway.beta.track3d.ai`
</Note>

This guide walks through the full integration flow in the order you will use it.

<Steps>
  <Step title="Sign in">
    Exchange your platform credentials for an auth token.
  </Step>

  <Step title="Download structure sheets">
    Get the floorplan PNG for each structure to use as your capture base.
  </Step>

  <Step title="Check for design changes">
    Confirm you are capturing against the latest sheets.
  </Step>

  <Step title="Upload captures">
    Push your captures to Track3D.
  </Step>
</Steps>

## Before you start

You need two credentials from Track3D.

| Credential | What it is | How you get it |
| - | - | - |
| Project API key | A key scoped to your project, sent as the `x-api-key` header | Shared with you by the Track3D team |
| Platform credentials | Your Track3D account email and password | Your Track3D user account |

You will also need your Project ID (e.g. `PRJ999999`), Structure IDs, and Design IDs, shared by the Track3D team along with your API key.

### Required headers for all Track3D API calls

| Header | Value |
| - | - |
| `authorization` | `Bearer <jwt>` |
| `x-api-key` | `<your-project-api-key>` |
| `x-project-id` | `<your-project-id>` |
| `content-type` | `application/json` (for requests with a body) |

<Note>
  The only exception is the direct S3 upload (Step 4c), which sends no headers.
</Note>

***

## Step 1 — Sign in

Exchange your platform credentials for an auth token (JWT). This token identifies you and carries your permissions on the platform. Every API call below requires it.

```http theme={null}
POST https://api.track3d.ai/api/v1/users/signin
```

**Request body**

```json theme={null}
{
  "email": "you@yourcompany.com",
  "password": "your-password"
}
```

**Response**

```json theme={null}
{
  "token": "<jwt>"
}
```

<Tip>
  Use this token as the `authorization: Bearer <jwt>` header on every call. Tokens expire after 24 hours. When a call returns 401, sign in again to get a fresh token.
</Tip>

***

## Step 2 — Project folder with default sheet

Generates a downloadable folder export for a project containing only each structure's default capture sheet (the PDF plus its `floorplan.png`). The export is zipped asynchronously, and once ready an email with the download link is sent to the requesting user.

```http theme={null}
GET https://gateway.beta.track3d.ai/progress/api/external-folders/project-folder-with-default-sheet?project={project_id}
```

**Mandatory headers**

| Header | Value | Required |
| - | - | - |
| `x-project-id` | `<project_id>` | Yes |
| `x-api-key` | `<api_key>` | Yes |
| `authorization` | `Bearer <auth-token>` | Yes |
| `content-type` | `application/json` | Yes |

**Query parameters**

| Param | Value | Required | Notes |
| - | - | - | - |
| `project` | `<project_id>` | Yes | Project id to export |
| `user` | `<user_id>` | No | Email recipient. Defaults to the authenticated user from the auth token, pass this only to override |

Request body: none (GET request).

**Response — `200 OK`**

```json theme={null}
{ "success": true, "message": "Project folder with default sheet is being generated. You will be notified once it's ready." }
```

<Note>
  The response returns immediately. The zip is built in the background. When it finishes, a download link is emailed to the user, valid for 7 days.
</Note>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X GET \
    "https://gateway.beta.track3d.ai/progress/api/external-folders/project-folder-with-default-sheet?project=<project_id>" \
    -H "x-project-id: <project_id>" \
    -H "x-api-key: <api_key>" \
    -H "authorization: Bearer <auth-token>" \
    -H "content-type: application/json"
  ```
</CodeGroup>

***

## Step 3 — Check for design changes

### Latest default design update time

Returns the most recent `updatedAt` timestamp across a project's default capture designs. Use it to decide whether a previously generated export is stale. If this timestamp is newer than your last download, the default sheets have changed and a fresh export should be requested using Step 2.

```http theme={null}
GET https://gateway.beta.track3d.ai/progress/api/external-folders/latest-default-design-update-time?projectId={project_id}
```

**Mandatory headers**

| Header | Value | Required |
| - | - | - |
| `x-project-id` | `<project_id>` | Yes |
| `x-api-key` | `<api_key>` | Yes |
| `authorization` | `Bearer <auth-token>` | Yes |
| `content-type` | `application/json` | Yes |

**Query parameters**

| Param | Value | Required | Notes |
| - | - | - | - |
| `projectId` | `<project_id>` | Yes | Project id to check. Note this is `projectId`, not `project` as in the export endpoint |

Request body: none (GET request).

**Response — `200 OK`**

```json theme={null}
{ "success": true, "data": { "latestDefaultDesignUpdatedAt": "2026-07-21T09:14:02.113Z" } }
```

`latestDefaultDesignUpdatedAt` is an ISO 8601 UTC timestamp, or `null` when the project has no default design.

<Accordion title="Error responses">
  | Status | Body | Cause |
  | - | - | - |
  | 400 | `{ "message": "Does not have access to the project" }` | Neither `x-project-id` nor `projectId` resolved to a project |
  | 404 | `{ "message": "Project not found" }` | Project id does not exist |
  | 403 | `{ "message": "Access restricted: Project is not in an allowed stage. Current stage: ..." }` | Project is in Pre-Setup or Deleted, only Setup, In-construction and Archived are allowed |
</Accordion>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X GET \
    "https://gateway.beta.track3d.ai/progress/api/external-folders/latest-default-design-update-time?projectId=<project_id>" \
    -H "x-project-id: <project_id>" \
    -H "x-api-key: <api_key>" \
    -H "authorization: Bearer <auth-token>" \
    -H "content-type: application/json"
  ```
</CodeGroup>

***

## Step 4 — Upload captures

Run Step 4a once per batch of files, then repeat Steps 4b–4d for every file in the manifest. A file is complete once all its parts are reported in Step 4d. Step 4e can be called any time.

### Supported video formats

The API accepts only two video formats, `.insv` and `.mp4`. Files in any other video format are rejected.

<Warning>
  Do not strip or modify file metadata on INSV files. Upload `.insv` files exactly as the camera produced them, with all file properties intact, including lens information. Track3D uses this metadata to determine how the capture was recorded.
</Warning>

* **Dual-lens cameras** (Insta360 X1, X2, RS): the camera produces two `.insv` files per capture, one per lens. Both files must be uploaded, and each must retain its lens metadata so Track3D can pair and stitch them correctly.
* **Single-file cameras** (Insta360 X4, X5): the camera produces one merged video file per capture.

<Steps>
  <Step title="Create capture and register files">
    Creates a capture and registers every file you intend to upload. Returns an `uploadManifest` containing one `uploadId` per file. You drive each file through Steps 4b–4d using its `uploadId`.

    ```http theme={null}
    POST https://gateway.beta.track3d.ai/captures/api/v1/external-captures/add-capture
    ```

    **Mandatory headers**

    | Header | Value | Required |
    | - | - | - |
    | `authorization` | `Bearer <jwt>` | Yes |
    | `x-api-key` | `<api_key>` | Yes |
    | `x-project-id` | `<project_id>` | Yes |
    | `content-type` | `application/json` | Yes |

    **Request body**

    ```json theme={null}
    {
      "captures": [
        {
          "project": "",
          "structure": "",
          "captureDateTime": "2026-02-25T10:00:00.000Z",
          "type": "exterior",
          "mode": "360 Video",
          "sourceType": "bot",
          "deviceId": "97088ABA-08B2-41FE-842B-662F3F2F8A66",
          "design": "DSG-2222222",
          "deviceInfo": [
            {
              "type": "primary",
              "deviceId": "97088ABA-08B2-41FE-842B-662F3F2F8A66",
              "deviceName": "iPhone 17e",
              "deviceOSVersion": "26.5.1",
              "deviceAPIVersion": "2.6.2 (3)"
            },
            {
              "type": "secondary",
              "deviceId": "IAHYA2503EK5FE",
              "deviceName": "Insta360 X5",
              "deviceOSVersion": "v1.11.6",
              "deviceAPIVersion": "0.100.38"
            }
          ],
          "metaDetails": {
            "frameRate": 30,
            "startPoint": [12.9716, 77.5946, 0],
            "direction": [1, 0, 0],
            "startTime": "2026-02-25T10:00:00.000Z"
          },
          "files": [
            {
              "frameIndex": 1,
              "fileName": "capture_360.insv",
              "contentType": "video/insv",
              "size": 268435456,
              "externalId": "ext-1"
            },
            {
              "frameIndex": 2,
              "fileName": "points.csv",
              "contentType": "text/csv",
              "size": 65536,
              "externalId": "ext-2"
            }
          ]
        }
      ]
    }
    ```

    | Field | Required | Description |
    | - | - | - |
    | `captures[].project` | Yes | Project id |
    | `captures[].structure` | Yes | Structure id |
    | `captures[].captureDateTime` | Yes | ISO timestamp of the capture |
    | `captures[].type` | Yes | `exterior` or `interior` |
    | `captures[].mode` | Yes | Capture mode, e.g. `360 Video` |
    | `captures[].sourceType` | Yes | `bot` |
    | `captures[].deviceId` | No | Id of the primary capture device |
    | `captures[].deviceInfo[]` | No | Devices used for the capture (array) |
    | `captures[].deviceInfo[].type` | Yes | `primary` or `secondary` |
    | `captures[].deviceInfo[].deviceId` | Yes | Device id |
    | `captures[].deviceInfo[].deviceName` | Yes | Device name, e.g. `DJI Mavic` |
    | `captures[].deviceInfo[].deviceOSVersion` | Yes | Device OS version |
    | `captures[].deviceInfo[].deviceAPIVersion` | Yes | Device API version |
    | `captures[].metaDetails` | No | Optional capture metadata object, mainly for video / 360 captures |
    | `captures[].metaDetails.frameRate` | No | Capture frame rate (frames per second) |
    | `captures[].metaDetails.type` | No | Capture type, e.g. `exterior` or `interior` |
    | `captures[].metaDetails.videoType` | No | Video type, e.g. `360` |
    | `captures[].metaDetails.cameraName` | No | Camera name, e.g. `Insta360 X5` |
    | `captures[].metaDetails.deviceID` | No | Device id of the capturing device |
    | `captures[].metaDetails.startPoint` | No | Start position as `[x, y, z]` |
    | `captures[].metaDetails.direction` | No | Heading/direction as `[x, y, z]` |
    | `captures[].metaDetails.startTime` | No | Start time of the capture (ISO timestamp) |
    | `captures[].files[].frameIndex` | Yes | 1-based sequence number of the image |
    | `captures[].files[].fileName` | Yes | File name |
    | `captures[].files[].contentType` | Yes | MIME type, e.g. `video/insv`, `application/json`, `text/csv` |
    | `captures[].files[].size` | Yes | File size in bytes, must match the real file |
    | `captures[].files[].externalId` | No | Your own id for the image (camera-roll / device id) |

    **Response — `200 OK`**

    ```json theme={null}
    {
      "success": true,
      "result": [
        {
          "capture": {
            "captureId": "",
            "project": "",
            "structure": "",
            "uploadStatus": "pending",
            "expectedFiles": 2,
            "completedFiles": 0
          },
          "uploadManifest": [
            {
              "frameIndex": 1,
              "uploadId": "UPL_f001",
              "fileName": "capture_360.insv",
              "size": 268435456,
              "partSize": 5242880,
              "totalParts": 52,
              "status": "initiated"
            },
            {
              "frameIndex": 2,
              "uploadId": "UPL_f002",
              "fileName": "points.csv",
              "size": 65536,
              "partSize": 5242880,
              "totalParts": 1,
              "status": "initiated"
            }
          ]
        }
      ]
    }
    ```

    | Field | Description |
    | - | - |
    | `result[].capture.captureId` | Id of the created capture, used in Step 4e |
    | `result[].capture.expectedFiles` | Total files registered for this capture |
    | `result[].uploadManifest[].uploadId` | Use this in Steps 4b–4d to upload the file |
    | `result[].uploadManifest[].partSize` | Byte size of each part/chunk |
    | `result[].uploadManifest[].totalParts` | How many parts the file is split into. Files smaller than `partSize` have 1 part |

    <Accordion title="Error responses">
      **No more than 3 files allowed** — `422 Unprocessable Entity`

      ```json theme={null}
      { "success": false, "message": "No more than 3 files are allowed" }
      ```

      **File name is missing** — `422 Unprocessable Entity`. Returned when any file does not contain a valid `fileName`.

      ```json theme={null}
      { "success": false, "message": "Each file must include a fileName" }
      ```

      **Unsupported file type** — `422 Unprocessable Entity`. Returned when a file is not an INSV, MP4, or CSV file. Extension validation is case-insensitive.

      ```json theme={null}
      { "success": false, "message": "Only INSV, MP4, and CSV files are allowed" }
      ```

      **Video file is missing** — `422 Unprocessable Entity`. Returned when the files contain no INSV or MP4 file. A CSV file cannot be submitted by itself.

      ```json theme={null}
      { "success": false, "message": "At least one INSV or MP4 file is required, CSV is optional" }
      ```
    </Accordion>
  </Step>

  <Step title="Presign file parts">
    For one file (`uploadId`), returns a short-lived S3 URL for each part still needing upload. Small files return a single part, larger files return several. Presign a file just before you upload it, the URLs expire.

    ```http theme={null}
    POST https://gateway.beta.track3d.ai/t3d-upload/uploads/UPL_f001/parts/presign-missing
    ```

    `UPL_f001` is the `uploadId` from Step 4a.

    **Mandatory headers**

    | Header | Value | Required |
    | - | - | - |
    | `authorization` | `Bearer <jwt>` | Yes |
    | `x-api-key` | `<api_key>` | Yes |
    | `x-project-id` | `<project_id>` | Yes |

    Request body: none.

    **Response — `200 OK`**

    ```json theme={null}
    {
      "ok": true,
      "uploadId": "UPL_f001",
      "partSize": 5242880,
      "totalParts": 2,
      "parts": [
        { "partNumber": 1, "url": "https://track3d-uploads.s3.amazonaws.com/...&X-Amz-Signature=...", "expiresIn": 3600 },
        { "partNumber": 2, "url": "https://track3d-uploads.s3.amazonaws.com/...&X-Amz-Signature=...", "expiresIn": 3600 }
      ]
    }
    ```

    | Field | Description |
    | - | - |
    | `parts[].partNumber` | 1-based index of the part |
    | `parts[].url` | Presigned S3 URL, PUT the part's bytes here in Step 4c |
    | `parts[].expiresIn` | URL validity in seconds. Upload before it expires |

    <Tip>
      If `parts` is empty, the file is already fully uploaded, skip to the next file.
    </Tip>
  </Step>

  <Step title="Upload a part to S3">
    Uploads the bytes of one part directly to Amazon S3 using the presigned URL from Step 4b. Send no authentication headers. Slice the file so that part N covers bytes `(N-1) * partSize` to `N * partSize`. Save the returned `ETag`.

    ```http theme={null}
    PUT <parts[].url from Step 4b>
    ```

    Mandatory headers: none.

    Request body: the raw bytes of the part (binary). For a single-part file, this is the whole file.

    **Response — `200 OK`**

    No body. Read the `ETag` response header and keep it for Step 4d.

    ```text theme={null}
    ETag: "d41d8cd98f00b204e9800998ecf8427e"
    ```

    <Tip>
      You may upload multiple parts, and multiple files, in parallel for higher throughput.
    </Tip>
  </Step>

  <Step title="Report completed parts">
    Reports the ETag of every uploaded part for a file. When the last part is reported, Track3D finalizes the file in S3 automatically. Send all parts of the file in one call.

    ```http theme={null}
    POST https://gateway.beta.track3d.ai/t3d-upload/uploads/UPL_f001/parts/complete-batch
    ```

    **Mandatory headers**

    | Header | Value | Required |
    | - | - | - |
    | `authorization` | `Bearer <jwt>` | Yes |
    | `x-api-key` | `<api_key>` | Yes |
    | `x-project-id` | `<project_id>` | Yes |
    | `content-type` | `application/json` | Yes |

    **Request body**

    ```json theme={null}
    {
      "parts": [
        { "partNumber": 1, "etag": "\"9a0364b9e99bb480dd25e1f0284c8555\"", "size": 5242880 },
        { "partNumber": 2, "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", "size": 3145728 }
      ]
    }
    ```

    | Field | Required | Description |
    | - | - | - |
    | `parts[].partNumber` | Yes | Part index from Step 4b |
    | `parts[].etag` | Yes | Exact ETag header returned by S3 in Step 4c, keep the quotes |
    | `parts[].size` | Yes | Byte length of the part |

    **Response — `200 OK`**

    ```json theme={null}
    {
      "ok": true,
      "upload": {
        "uploadId": "UPL_f001",
        "status": "completed",
        "fileName": "capture_360.insv",
        "size": 8388608,
        "totalParts": 2,
        "uploadedPartsCount": 2,
        "percentage": 100,
        "missingParts": []
      }
    }
    ```

    | Field | Description |
    | - | - |
    | `status` | `completed` once all parts are in, otherwise `uploading` |
    | `percentage` | Upload progress for this file |
    | `missingParts` | Parts still not uploaded, empty when complete |
  </Step>

  <Step title="Check upload status (optional)">
    Returns overall progress for a capture, how many files have finished. Use it to confirm all uploads landed before triggering downstream work.

    ```http theme={null}
    GET https://gateway.beta.track3d.ai/captures/api/v1/external-captures/CAP_ext789/upload-status
    ```

    `CAP_ext789` is the `captureId` from Step 4a.

    **Mandatory headers**

    | Header | Value | Required |
    | - | - | - |
    | `authorization` | `Bearer <jwt>` | Yes |
    | `x-api-key` | `<api_key>` | Yes |
    | `x-project-id` | `<project_id>` | Yes |

    Request body: none.

    **Response — `200 OK`**

    ```json theme={null}
    {
      "success": true,
      "captureId": "CAP_ext789",
      "uploadStatus": "uploading",
      "expectedFiles": 2,
      "completedFiles": 1,
      "percentage": 50
    }
    ```

    | Field | Description |
    | - | - |
    | `uploadStatus` | `pending`, `uploading`, or `complete` |
    | `expectedFiles` | Total files registered |
    | `completedFiles` | Files fully uploaded so far |
    | `percentage` | Overall completion percentage |
  </Step>
</Steps>

***

## Retry and resilience

Uploads are designed to survive network blips, expired URLs, and full process restarts. The client SDK (`@track3d/uploader-web`) implements a three-layer retry model on top of the server's resumability. If you build your own client, replicate these layers.

| Layer | Scope | What it recovers | Driver |
| - | - | - | - |
| 1. Part PUT retry | A single S3 PUT (Step 4c) | Transient network / 5xx errors | Automatic |
| 2. Session resume loop | One file (`uploadId`) | Expired URLs, partial progress, interruptions | Automatic |
| 3. Queue retry | A whole file/entity | Terminal failure after layers 1–2 are exhausted | App / user |

### Layer 1 — Part PUT retry

Each part PUT (Step 4c) is retried on transient failure before the part is considered failed.

| Setting | Default | Description |
| - | - | - |
| Max retries | 3 | Attempts after the first try (`partPutRetries`) |
| Backoff base | 1000 ms | Delay is `base * 2^attempt`, so 1s, 2s, 4s |

<Note>
  A 403 (expired presigned URL) is not retried here, it is surfaced to Layer 2, which re-signs fresh URLs. A missing ETag response header is treated as a failed part and retried. Cancellation short-circuits immediately with no further retries.
</Note>

### Layer 2 — Session resume loop

For each file, the client loops until the file is complete:

```text theme={null}
while (upload is resumable AND parts are still missing):
  1. POST .../parts/presign-missing → get URLs for ONLY the missing parts
  2. PUT each returned part to S3 (Layer 1 applies here)
  3. POST .../parts/complete-batch → report ETags
  4. GET .../status?syncFromS3=true → reconcile against S3
```

`presign-missing` returns only parts not yet in S3, so a resumed upload never re-sends bytes that already landed. On a 403 / expired URL during a PUT, the client re-syncs status and loops to re-presign, no data is lost. This is the same mechanism as a manual resume: call Step 4b again for the same `uploadId` at any time.

### Layer 3 — Queue retry

When layers 1–2 are exhausted, a file is marked `failed`. The app (or user) can re-queue failed items. Because of Layer 2 they resume from the last uploaded part, not from zero.

### Recommended backoff for custom clients

If you implement your own uploader, wrap every S3 PUT and Track3D API call in exponential backoff with jitter.

| Setting | Suggested value | Description |
| - | - | - |
| Max retries | 4 | Attempts after the first try |
| Backoff base | 500 ms | Delay is `base * 2^attempt + random(0, base)` |

Classify errors before retrying.

| Error | Retry? | Action |
| - | - | - |
| Network error / timeout | Yes | Backoff and retry the same call |
| S3 5xx | Yes | Backoff and retry the same PUT |
| S3 RequestTimeout (a 4xx) | Yes | Transient under load, backoff and retry |
| S3 403 / AccessDenied (expired URL) | Re-sign | Call Step 4b again, then retry the PUT with the new URL |
| Other 4xx (e.g. validation) | No | Permanent, fail the file and report it |

<Warning>
  On every retry, send the exact ETag S3 returned for the successful PUT, including the surrounding quotes.
</Warning>

***

## Quick reference

| Step | Task | Method | Endpoint |
| - | - | - | - |
| 1 | Sign in | POST | `https://api.track3d.ai/api/v1/users/signin` |
| 2 | Download structure sheets | GET | `/progress/api/external-folders/project-folder-with-default-sheet?project=<id>` |
| 3 | Check for design changes, before each capture | GET | `/progress/api/external-folders/latest-default-design-update-time?projectId=<project_id>` |
| 4a | Create capture and register files | POST | `/captures/api/v1/external-captures/add-capture` |
| 4b | Presign file parts | POST | `/t3d-upload/uploads/{uploadId}/parts/presign-missing` |
| 4c | Upload part to S3 | PUT | Presigned URL from 4b, no headers |
| 4d | Report ETags | POST | `/t3d-upload/uploads/{uploadId}/parts/complete-batch` |
| 4e | Check upload status | GET | `/captures/api/v1/external-captures/{captureId}/upload-status` |

## Support

If you run into issues with credentials, permissions (401/403 responses), or upload failures, contact your Track3D point of contact with your project ID and the failing request details, excluding your API key and token.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.