Developer guide

File Upload API

Upload one large file or many files over plain HTTP, then create a project. Nothing here is language-specific — any HTTP client (libcurl, WinHTTP, .NET, Java, Go, Python …) can implement it.

Base URLhttps://360-for-you.com/api/v1
AuthAuthorization: Bearer sk_…

Every request needs the header Authorization: Bearer sk_… — create a key on your profile page. The key needs the projects:write scope (uploads + project creation) and projects:read (to poll project status); the default key has both. Responses are JSON; errors look like {"error": "<code>"} with the HTTP status carrying the meaning.

The flow

  1. 1
    POST /uploads — create a session, get an upload_id.
  2. 2
    PUT /uploads/{upload_id}/{filename} — upload each file (whole, or in chunks). Repeat for every file; all files share one session.
  3. 3
    GET /uploads/{upload_id}(optional) verify all files are complete.
  4. 4
    POST /projects with {"upload_id": "…"} — create one project from the session.
  5. 5
    GET /projects/{name} — poll until status == 100 (ready).

A single file is just step 2 done once. Many files = step 2 repeated into the same upload_id. You may also upload one archive (.zip, .7z, .rar) — the server unpacks it.

Endpoints

POST/uploads

Create an upload session. No body.

201 {"upload_id": "Bx0xyTjt", "expires_in_seconds": 259200}

upload_id is opaque (treat it as a token). Abandoned sessions are deleted after expires_in_seconds (~72 h).

402 storage_quota_exceeded 403 tariff_forbids_upload

PUT/uploads/{upload_id}/{filename}

Upload a whole file, or one chunk of it. Body = the raw bytes.

HeaderRequiredValue
AuthorizationyesBearer sk_…
Content-Typeyesapplication/octet-stream
Content-Rangechunks onlybytes <start>-<end>/<total>
  • Whole file (one request): omit Content-Range; send the full body with a correct Content-Length. Best for small files.
  • Chunked: send Content-Range: bytes <start>-<end>/<total> where end is inclusive and <total> is the full file size. The body length must equal end - start + 1.

200 {"received": <int>, "total": <int>, "complete": <bool>}

received = bytes the server now holds (the resume high-water mark). complete turns true when received == total; the file is finalized at that moment.

400 invalid_upload_id · invalid_filename · invalid_content_range · chunk_length_mismatch
404 upload_session_not_found 409 non_contiguous_chunk (includes received — resume from there)
411 length_required (single-shot PUT without Content-Length) 413 file_too_large · session_too_large (includes limit)
415 extension_not_allowed (includes extension and the allowed list)

GET/uploads/{upload_id}/{filename}

One file's progress — use it to resume.

200 {"received": <int>, "total": <int>, "complete": <bool>}   404 file_not_found

GET/uploads/{upload_id}

List every file in the session and its progress.

200 {"upload_id": "…", "files": [{"name": "…", "received": …, "total": …, "complete": …}]}

DELETE/uploads/{upload_id}

Discard a session and everything in it (idempotent).

200 {"deleted": true}

POST/projects

Create one project from the session. JSON body:

{ "upload_id": "Bx0xyTjt", "title": "My project", "privacy_mode": 2 }
  • Provide either upload_id or download_link (a remote URL) — not both, not neither.
  • title is optional (auto-generated if omitted). Many other optional processing fields exist (privacy, coordinate system, anonymization, point-cloud and video options) — see the interactive reference.
  • All files in the session must be complete first, otherwise creation fails.

201 {"name": "Bx0xyTjt", "status": 0, "message": "processing_started"}

name is the project's URL slug — authoritative; it may differ from upload_id on a rare collision.

400 upload_id_or_download_link_required · provide_either_upload_id_or_download_link · invalid_upload_id · project_creation_failed (a file isn't fully uploaded yet)
404 upload_session_not_found 402 storage_quota_exceeded 403 tariff

GET/projects/{name}

Poll project status. status: 0 = queued, 1…99 = processing, 100 = ready. While below 100 only {name, status} is returned; at 100 the full record is returned.

Chunking & resume rules

  • Chunks must be contiguous and in order — each chunk's start equals the previous received. A gap is rejected with 409 and the current received.
  • Re-sending a chunk is safe (idempotent): overlapping bytes overwrite in place. Re-PUTting an already-complete file returns complete: true unchanged.
  • One in-flight PUT per file. To go faster, upload different files in parallel — never parallel chunks of the same file.
  • Resume after any failure: GET the file status, then continue PUTing from received. This is the recommended pattern for desktop apps on unstable networks.

Resume-safe upload (pseudocode)

CHUNK = 8 * 1024 * 1024                       # 8 MiB; any size is fine

function upload_file(upload_id, path):
    size = filesize(path); name = basename(path)

    resp = GET  /uploads/{upload_id}/{name}             # how much does the server already have?
    received = (resp.status == 200) ? resp.body.received : 0

    while received < size:
        chunk = read(path, offset=received, length=CHUNK)
        end   = received + length(chunk) - 1
        resp  = PUT /uploads/{upload_id}/{name}
                    headers: Content-Type:  application/octet-stream
                             Content-Range: bytes {received}-{end}/{size}
                    body: chunk
        switch resp.status:
            200: received = resp.body.received          # advance to server high-water
            409: received = resp.body.received; continue # resync, then retry
            5xx / network error: sleep(backoff); continue # retry is safe (idempotent)
            else: abort(resp)                            # 4xx (413/415/…) -> fix and stop
    # now resp.body.complete == true

Filenames, limits, pacing

  • Filenames are flattened. Path separators and unsafe characters are stripped server-side; nested folders are not supported — send a flat name, or upload an archive for a tree. Don't send two files with the same name into one session (the second is ignored).
  • Allowed extensions are server-configured (images, point clouds, CAD/BIM incl. .ifc, gaussian splats, 360 video, archives, …). A disallowed file returns 415 with the full allowed list.
  • Size caps (per file and per session) are server-configured; exceeding returns 413 with limit.

Rate limits — HTTP 429 when exceeded; back off and retry:

EndpointLimit
POST /uploads30 / hour
PUT /uploads/…/{filename}1200 / minute
GET /uploads/…/{filename}600 / minute
GET /uploads/{upload_id}120 / minute
DELETE /uploads/{upload_id}60 / minute
POST /projects10 / hour
GET /projects/{name}60 / minute

Examples

Python — resumable, one or many files (requests)

import os, requests

BASE  = "https://360-for-you.com/api/v1"
KEY   = os.environ["API_KEY"]                  # never hard-code the key
CHUNK = 8 * 1024 * 1024                         # 8 MiB; any size is fine

s = requests.Session()
s.headers["Authorization"] = f"Bearer {KEY}"

def upload_file(upload_id, path):
    size = os.path.getsize(path); name = os.path.basename(path)
    r = s.get(f"{BASE}/uploads/{upload_id}/{name}")            # how much is already there? (resume)
    received = r.json()["received"] if r.status_code == 200 else 0
    with open(path, "rb") as f:
        while received < size:
            f.seek(received)
            chunk = f.read(CHUNK)
            end = received + len(chunk) - 1
            r = s.put(f"{BASE}/uploads/{upload_id}/{name}", data=chunk,
                      headers={"Content-Type": "application/octet-stream",
                               "Content-Range": f"bytes {received}-{end}/{size}"})
            if r.status_code == 409:                           # gap: resync to server offset, retry
                received = r.json()["received"]; continue
            r.raise_for_status()
            received = r.json()["received"]                    # advance to the server high-water mark

def create_project(paths, title):
    upload_id = s.post(f"{BASE}/uploads").json()["upload_id"]
    for path in paths:                                         # one file or many -> one session
        upload_file(upload_id, path)
    r = s.post(f"{BASE}/projects", json={"title": title, "upload_id": upload_id})
    r.raise_for_status()
    return r.json()["name"]                                    # project URL slug

slug = create_project(["floor-1.e57", "floor-2.e57"], "Building scan")
print("https://360-for-you.com/projects/" + slug)

Download the full example — same flow with retries, resume across runs (API_UPLOAD_ID) and one-or-many files; reads the key from API_KEY.

One big file, chunked & resumable (curl)

KEY=sk_...; BASE=https://360-for-you.com/api/v1
ID=$(curl -s -X POST $BASE/uploads -H "Authorization: Bearer $KEY" | jq -r .upload_id)

# one chunk (repeat per slice, advancing the range):
curl -X PUT "$BASE/uploads/$ID/scan.e57" -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/octet-stream" \
     -H "Content-Range: bytes 0-104857599/524288000" --data-binary @scan.e57.part0
# resume after a drop: GET .../scan.e57 -> {"received": N}, continue from N

curl -X POST $BASE/projects -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d "{\"title\":\"Big scan\",\"upload_id\":\"$ID\"}"

Many small files, one project (curl)

ID=$(curl -s -X POST $BASE/uploads -H "Authorization: Bearer $KEY" | jq -r .upload_id)
for f in *.jpg; do
  curl -X PUT "$BASE/uploads/$ID/$f" -H "Authorization: Bearer $KEY" \
       -H "Content-Type: application/octet-stream" --data-binary @"$f"   # no Content-Range = whole file
done
curl -X POST $BASE/projects -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d "{\"title\":\"Photoset\",\"upload_id\":\"$ID\"}"

Resources

AI Assistant
Hello! Feel free to ask any questions about virtual tours, point clouds, 3D Gaussian Splatting, 3D models, using the website, payments, and more. I'll either find the answer or forward your question to our support team.
Our AI could not answer your question. Our support team will be happy to answer your question. Please provide your email address. We do not use email for newsletters. We only use it to answer your question.