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
POST /uploads— create a session, get anupload_id. - 2
PUT /uploads/{upload_id}/{filename}— upload each file (whole, or in chunks). Repeat for every file; all files share one session. - 3
GET /uploads/{upload_id}— (optional) verify all files are complete. - 4
POST /projectswith{"upload_id": "…"}— create one project from the session. - 5
GET /projects/{name}— poll untilstatus == 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
/uploadsCreate 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
/uploads/{upload_id}/{filename}Upload a whole file, or one chunk of it. Body = the raw bytes.
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer sk_… |
Content-Type | yes | application/octet-stream |
Content-Range | chunks only | bytes <start>-<end>/<total> |
- Whole file (one request): omit
Content-Range; send the full body with a correctContent-Length. Best for small files. - Chunked: send
Content-Range: bytes <start>-<end>/<total>whereendis inclusive and<total>is the full file size. The body length must equalend - 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)
/uploads/{upload_id}/{filename}One file's progress — use it to resume.
200 {"received": <int>, "total": <int>, "complete": <bool>}
404 file_not_found
/uploads/{upload_id}List every file in the session and its progress.
200 {"upload_id": "…", "files": [{"name": "…", "received": …, "total": …, "complete": …}]}
/uploads/{upload_id}Discard a session and everything in it (idempotent).
200 {"deleted": true}
/projectsCreate one project from the session. JSON body:
{ "upload_id": "Bx0xyTjt", "title": "My project", "privacy_mode": 2 }
- Provide either
upload_idordownload_link(a remote URL) — not both, not neither. titleis 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
completefirst, 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
/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
startequals the previousreceived. A gap is rejected with 409 and the currentreceived. - Re-sending a chunk is safe (idempotent): overlapping bytes overwrite in place. Re-
PUTting an already-complete file returnscomplete: trueunchanged. - One in-flight
PUTper file. To go faster, upload different files in parallel — never parallel chunks of the same file. - Resume after any failure:
GETthe file status, then continuePUTing fromreceived. 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 fullallowedlist. - 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:
| Endpoint | Limit |
|---|---|
POST /uploads | 30 / hour |
PUT /uploads/…/{filename} | 1200 / minute |
GET /uploads/…/{filename} | 600 / minute |
GET /uploads/{upload_id} | 120 / minute |
DELETE /uploads/{upload_id} | 60 / minute |
POST /projects | 10 / 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\"}"