Your First Integration
A complete, runnable walk-through. We'll build a small script that uploads a local folder to a ScaiDrive share, generates a password-protected external link for the root folder, and prints the URL. Along the way we'll cover error handling, retries, and graceful auth.
What you'll build
flowchart LR
L["local folder"] -->|upload| S["ScaiDrive share"] -->|create link| U["shareable URL"]
Requirements:
- ScaiDrive URL and a service token.
- A share you have
contributor or higher role on.
- Python 3.11+ (or Node 20+ for the TypeScript version).
1. Set up credentials
| export SCAIDRIVE_URL="https://scaidrive.scailabs.ai"
export SCAIDRIVE_TOKEN="eyJhbGc..."
export SCAIDRIVE_SHARE_ID="shr_01J3KXQ0N"
|
2. Create a client wrapper
A thin wrapper that handles the base URL, auth header, token refresh, and retry-on-rate-limit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49 | # scaidrive_client.py
import os
import time
import httpx
class ScaiDriveError(Exception):
def __init__(self, code: str, message: str, status: int, request_id: str):
super().__init__(f"{code}: {message}")
self.code, self.status, self.request_id = code, status, request_id
class ScaiDrive:
def __init__(self, base_url: str, token: str, timeout: float = 30.0):
self._client = httpx.Client(
base_url=base_url.rstrip("/"),
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
)
def request(self, method: str, path: str, **kw):
for attempt in range(4):
resp = self._client.request(method, path, **kw)
if resp.status_code == 429:
delay = int(resp.headers.get("Retry-After", "2"))
time.sleep(delay * (2**attempt))
continue
if resp.status_code >= 500 and attempt < 3:
time.sleep(2**attempt)
continue
break
if resp.status_code >= 400:
body = resp.json() if resp.headers.get("Content-Type", "").startswith("application/json") else {}
err = (body.get("error") or {})
raise ScaiDriveError(
code=err.get("code", "UNKNOWN"),
message=err.get("message", resp.text),
status=resp.status_code,
request_id=(body.get("meta") or {}).get("request_id", ""),
)
return resp
def close(self) -> None:
self._client.close()
def from_env() -> ScaiDrive:
return ScaiDrive(os.environ["SCAIDRIVE_URL"], os.environ["SCAIDRIVE_TOKEN"])
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48 | // scaidrive-client.ts
export class ScaiDriveError extends Error {
constructor(
public code: string,
message: string,
public status: number,
public requestId: string,
) {
super(`${code}: ${message}`);
}
}
export class ScaiDrive {
constructor(
private baseUrl: string,
private token: string,
) {}
async request(method: string, path: string, init: RequestInit = {}): Promise<Response> {
const url = `${this.baseUrl.replace(/\/$/, "")}${path}`;
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${this.token}`);
for (let attempt = 0; attempt < 4; attempt++) {
const resp = await fetch(url, { ...init, method, headers });
if (resp.status === 429) {
const retry = Number(resp.headers.get("Retry-After") ?? 2);
await new Promise((r) => setTimeout(r, retry * 1000 * 2 ** attempt));
continue;
}
if (resp.status >= 500 && attempt < 3) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
continue;
}
if (resp.status >= 400) {
const body = (await resp.json().catch(() => ({}))) as any;
throw new ScaiDriveError(
body?.error?.code ?? "UNKNOWN",
body?.error?.message ?? resp.statusText,
resp.status,
body?.meta?.request_id ?? "",
);
}
return resp;
}
throw new ScaiDriveError("RETRIES_EXHAUSTED", "rate-limit retries exhausted", 429, "");
}
}
|
3. Walk the local folder
Nothing ScaiDrive-specific here — just walk the tree, collect files.
| from pathlib import Path
def walk(root: Path):
for p in root.rglob("*"):
if p.is_file():
rel = p.relative_to(root)
yield p, rel
|
| import { readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
function* walk(root: string): Generator<{ abs: string; rel: string }> {
for (const entry of readdirSync(root, { withFileTypes: true })) {
const abs = join(root, entry.name);
if (entry.isDirectory()) yield* walk(abs);
else yield { abs, rel: relative(root, abs) };
}
}
|
4. Create folders as needed
The API requires the target folder to exist before you upload a file into it. Build a cache of (path → folder_id) so we don't re-create the same folder per file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | def ensure_folder(client, share_id: str, rel_dir: Path, cache: dict[str, str]) -> str | None:
"""Returns folder_id for rel_dir inside share, creating folders as needed."""
if str(rel_dir) in (".", ""):
return None # share root
if str(rel_dir) in cache:
return cache[str(rel_dir)]
parent_id = ensure_folder(client, share_id, rel_dir.parent, cache)
resp = client.request(
"POST", "/api/v1/folders",
json={"share_id": share_id, "name": rel_dir.name, "parent_id": parent_id},
)
folder_id = resp.json()["id"]
cache[str(rel_dir)] = folder_id
return folder_id
|
5. Upload each file
| def upload_file(client, share_id: str, folder_id: str | None, abs_path: Path):
with abs_path.open("rb") as f:
data = {"share_id": share_id}
if folder_id:
data["folder_id"] = folder_id
resp = client.request(
"POST", "/api/v1/files",
data=data,
files={"file": (abs_path.name, f)},
)
return resp.json()
|
| async function uploadFile(client: ScaiDrive, shareId: string, folderId: string | null, absPath: string, relName: string) {
const form = new FormData();
form.append("share_id", shareId);
if (folderId) form.append("folder_id", folderId);
form.append("file", new Blob([readFileSync(absPath)]), relName);
const resp = await client.request("POST", "/api/v1/files", { body: form });
return await resp.json();
}
|
6. Generate the external link
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | def create_link(client, share_id: str, folder_id: str, password: str):
resp = client.request(
"POST", "/api/v1/external/links",
json={
"resource_type": "folder",
"resource_id": folder_id,
"share_id": share_id,
"link_type": "DOWNLOAD",
"password": password,
"expires_in_days": 14,
"custom_name": "Shared batch",
},
)
return resp.json()
|
7. Put it all together
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 | # upload_and_share.py
import sys
from pathlib import Path
from scaidrive_client import from_env
def main(local_dir: str, password: str):
client = from_env()
share_id = os.environ["SCAIDRIVE_SHARE_ID"]
root = Path(local_dir)
cache: dict[str, str] = {}
root_folder_id = ensure_folder(client, share_id, Path(root.name), cache)
for abs_path, rel in walk(root):
folder_id = ensure_folder(client, share_id, Path(root.name) / rel.parent, cache)
meta = upload_file(client, share_id, folder_id, abs_path)
print(f"uploaded {rel} -> {meta['id']}")
link = create_link(client, share_id, root_folder_id, password)
print(f"\nShare URL: {link['url']}")
print(f"Password: {password}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
|
Run:
| python upload_and_share.py ./my_photos hunter2
|
What to do when things fail
401 AUTH_TOKEN_EXPIRED — Your token expired. Refresh with a refresh_token grant or obtain a new service token.
403 AUTHZ_PERMISSION_DENIED — The user doesn't have permission on the target share. Check their role in /api/v1/users/me/shares.
409 SYNC_CONFLICT — A concurrent writer beat you. For uploads the API retries idempotently; for metadata updates, re-read the resource and retry with the latest base_version_id.
413 PAYLOAD_TOO_LARGE — File is over the tenant's per-file size limit. Use Resumable Uploads for large files.
429 RATE_LIMITED — Respect Retry-After. The client above does this automatically.
507 QUOTA_EXCEEDED — The share, user, or tenant is over quota. Free up space or raise the quota. See Quotas.
See Errors for the full vocabulary.
What's next
- Files — full file API, including copy, move, and versioning.
- Sharing — internal shares, roles, and invitations.
- Sync — if you're building a client that keeps a local folder in sync.