TypeScript SDK
The official TypeScript / JavaScript client for the FluidCloud Partner API. It wraps the REST API described in the API reference: it attaches your API key, hides the presign → direct-PUT → complete upload flow behind a single files.upload() call, and maps HTTP errors to typed exceptions.
The SDK talks to the same endpoints documented in the reference, so anything you can do here you can also do with raw HTTP.
Install
npm install fluidcloudRequirements:
- Node 18+ (the SDK uses the global
fetchandcrypto.subtle; both ship with Node 18+). It also runs in modern browsers and edge runtimes that exposefetch. - The package is ESM. Import it with
import, or use a bundler /"type": "module". In CommonJS, load it with dynamicimport().
You need an API key. Keys are created in the FluidCloud dashboard under Settings → Developer → API keys (an active subscription is required). They cannot be created via the API. Keep keys server-side — see Security.
Construct the client
import { FluidCloud } from "fluidcloud";
const fc = new FluidCloud({ apiKey: process.env.FLUIDCLOUD_API_KEY! });The constructor takes a single options object (FluidCloudOptions):
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | — (required) | Your key (fck_live_… / fck_test_…). Sent as X-API-Key. Throws if missing. |
baseUrl | string | https://api-cloud.fluidvip.com | API origin. The SDK appends /api/v1 for you. Trailing slashes are trimmed. |
apiVersion | string | "v1" | API version segment. Leave at the default. |
fetch | typeof fetch | the runtime's global fetch | Override the fetch implementation (tests, or runtimes without a global fetch). |
The client picks the auth header from your key: keys starting with fck_ are sent as X-API-Key; otherwise the value is sent as Authorization: Bearer …. See Authentication.
If your runtime has no global fetch and you do not pass options.fetch, the constructor throws.
Resources
The client exposes five resource namespaces:
| Namespace | Class | What it covers |
|---|---|---|
fc.spaces | Spaces | Top-level containers (Spaces) |
fc.folders | Folders | Folder tree inside a space (Folders) |
fc.files | Files | Upload, list, fetch, move, delete, share files (Files, Uploads) |
fc.shares | Shares | List and revoke share links (Shares) |
fc.quota | QuotaResource | Read storage + share-link usage (Quota) |
Every method returns a Promise. All ids are UUID strings; all timestamps are ISO-8601 UTC strings.
fc.spaces
fc.spaces.list(): Promise<Space[]>
fc.spaces.create(name: string): Promise<Space>| Method | HTTP | Scope | Returns |
|---|---|---|---|
list() | GET /spaces | spaces:read | Space[] |
create() | POST /spaces | spaces:write | Space |
const spaces = await fc.spaces.list();
const space = await fc.spaces.create("partner-prod");fc.folders
fc.folders.list(
spaceId: string,
parentId?: string | null, // default null = root of the space
opts?: { trash?: boolean }, // true = list trashed folders
): Promise<Folder[]>
fc.folders.create(
name: string,
spaceId: string,
parentId?: string | null, // default null = root
): Promise<Folder>
fc.folders.rename(folderId: string, name: string): Promise<Folder>
fc.folders.move(folderId: string, parentId: string | null): Promise<Folder>
fc.folders.delete(folderId: string): Promise<Folder> // soft-delete (to trash)
fc.folders.restore(folderId: string): Promise<Folder> // restore from trash| Method | HTTP | Scope | Returns |
|---|---|---|---|
list() | GET /folders | folders:read | Folder[] |
create() | POST /folders | folders:write | Folder |
rename() | PATCH /folders/{id} | folders:write | Folder |
move() | PATCH /folders/{id} | folders:write | Folder |
delete() | DELETE /folders/{id} | folders:write | Folder |
restore() | POST /folders/{id}/restore | folders:write | Folder |
delete() is a soft-delete: the folder moves to trash and restore() brings it back. See Organizing files and Deleting data.
const folder = await fc.folders.create("job-7", space.id);
const subfolder = await fc.folders.create("out", space.id, folder.id);
await fc.folders.rename(folder.id, "job-7-archive");
await fc.folders.move(subfolder.id, null); // move to space rootfc.files
// Upload (presign -> direct PUT -> complete, incl. multipart) — see below
fc.files.upload(
file: UploadInput,
spaceId: string,
options?: UploadOptions,
): Promise<FileItem>
fc.files.list(
spaceId: string,
folderId?: string | null, // default null = space root
opts?: { trash?: boolean },
): Promise<FileItem[]>
fc.files.get(fileId: string): Promise<FileItem>
fc.files.resolve(clientKey: string): Promise<FileItem> // look up by YOUR client_key
fc.files.rename(fileId: string, name: string): Promise<FileItem>
fc.files.move(fileId: string, folderId: string | null): Promise<FileItem>
fc.files.delete(fileId: string): Promise<FileItem> // soft-delete (to trash)
fc.files.restore(fileId: string): Promise<FileItem> // restore from trash
fc.files.downloadUrl(fileId: string): Promise<string> // short-lived presigned GET
// Share helpers (mint links)
fc.files.publicUrl(fileId: string): Promise<Link> // permanent public hotlink
fc.files.signedUrl(
fileId: string,
opts?: { expiresInDays?: number; permission?: "view" | "download" },
): Promise<Link> // expiring link (default 7 days, "view")| Method | HTTP | Scope | Returns |
|---|---|---|---|
upload() | POST /uploads/initiate → PUT(s) → POST /uploads/complete | files:write | FileItem |
list() | GET /files | files:read | FileItem[] |
get() | GET /files/{id} | files:read | FileItem |
resolve() | GET /files/resolve | files:read | FileItem |
rename() | PATCH /files/{id} | files:write | FileItem |
move() | PATCH /files/{id} | files:write | FileItem |
delete() | DELETE /files/{id} | files:write | FileItem |
restore() | POST /files/{id}/restore | files:write | FileItem |
downloadUrl() | GET /files/{id}/download | files:read | string |
publicUrl() | POST /shares | shares:write | Link |
signedUrl() | POST /shares | shares:write | Link |
Notes:
resolve(clientKey)fetches the file you uploaded under your own logicalclient_key(e.g.results/job-7/out.png) without storing FluidCloud's file id. If no file matches the key it throwsNotFoundError. SeeUploadOptions.clientKeybelow.downloadUrl()returns a short-lived presigned GET URL for fetching the raw bytes yourself (not a shareable link).publicUrl()/signedUrl()mint share links and require theshares:writescope.publicUrl()makes a permanent, cacheable, inline hotlink (expires_at: null);signedUrl()makes an expiring one (1–365 days). The mintedurlis served from the cookieless raw-link domain,https://files.fluidadmin.com/s/<token>. Both succeed only once the file is scanned clean — see Quarantine-until-clean. For an overview of link types see Raw links.
const f = await fc.files.get(fileId);
const files = await fc.files.list(space.id, folder.id);
// Stable hotlink:
const { url } = await fc.files.publicUrl(f.id);
// -> https://files.fluidadmin.com/s/...
// 30-day download link:
const link = await fc.files.signedUrl(f.id, { expiresInDays: 30, permission: "download" });fc.shares
fc.shares.list(
opts?: { fileId?: string; includeInactive?: boolean },
): Promise<Share[]>
fc.shares.revoke(shareId: string): Promise<void>| Method | HTTP | Scope | Returns |
|---|---|---|---|
list() | GET /shares | shares:read | Share[] |
revoke() | DELETE /shares/{id} | shares:write | void |
list() returns active links by default; pass includeInactive: true to include revoked / expired ones, or fileId to filter to one file. See Shares.
Revoking a permanent public link stops new lookups, but already-cached responses can still be served from the edge for up to 3600 seconds (its max-age). Plan around this before relying on revocation for hard cut-off. A share-link
passwordis accepted and stored (has_password) but is not enforced at serve time — do not rely on it as an access control.
const shares = await fc.shares.list({ fileId: f.id });
await fc.shares.revoke(shares[0].id);fc.quota
fc.quota.usage(): Promise<Quota>| Method | HTTP | Scope | Returns |
|---|---|---|---|
usage() | GET /quota | quota:read | Quota |
Quota reports bytes_used / bytes_limit and links_used / links_limit. A limit of -1 means unlimited. See Quota & usage.
const q = await fc.quota.usage();
const pct =
q.bytes_limit === -1 ? 0 : (q.bytes_used / q.bytes_limit) * 100;
console.log(`storage ${pct.toFixed(1)}%, links ${q.links_used}/${q.links_limit}`);Uploading
fc.files.upload() runs the whole flow for you: it asks the API for a ticket (POST /uploads/initiate), PUTs your bytes directly to storage via the presigned URL(s) — for large files it transparently does a multipart upload across the returned part URLs — and then calls POST /uploads/complete. The API never proxies your bytes.
async upload(
file: UploadInput,
spaceId: string,
options?: UploadOptions,
): Promise<FileItem>The uploaded file is automatically scanned. It returns immediately with scan_status possibly still "pending"; you must wait for "clean" before you can share or serve it (see below).
Upload input types
type UploadInput = Blob | ArrayBuffer | Uint8Array;Blob/File— browserFile/Blob, or Node 18+ globalBlob. Itsnameandtypeare used when present.ArrayBuffer/Uint8Array— raw bytes (Node or browser).
When the input carries no name/type, pass them via options. The SDK guesses a content type from the file extension and falls back to application/octet-stream.
interface UploadOptions {
folderId?: string | null; // target folder; default = space root
name?: string; // original filename (for raw bytes / overrides)
contentType?: string; // MIME type override
public?: boolean; // also mint a permanent hotlink (sets result.public_url)
clientKey?: string; // YOUR logical key; later fetch via files.resolve()
}When public: true, the SDK additionally mints a permanent public hotlink and sets it on the returned FileItem.public_url — saving you a separate publicUrl() call. (Minting the link requires the shares:write scope.)
// From raw bytes in Node:
const item = await fc.files.upload(buf, space.id, {
name: "out.png",
contentType: "image/png",
folderId: folder.id,
clientKey: "results/job-7/out.png",
public: true,
});
console.log(item.public_url); // https://files.fluidadmin.com/s/...
// Later, without storing item.id:
const same = await fc.files.resolve("results/job-7/out.png");See Uploading for the full guide and Uploads for the underlying endpoints.
Quarantine-until-clean
Every upload is virus-scanned. A file must reach scan_status === "clean" before it can be shared or served; until then, publicUrl(), signedUrl(), and serving a link return 409 → ConflictError. Poll fc.files.get(id) until the status is clean:
async function waitClean(fc: FluidCloud, fileId: string): Promise<FileItem> {
for (;;) {
const f = await fc.files.get(fileId);
if (f.scan_status === "clean") return f;
if (f.scan_status === "infected") throw new Error("file failed virus scan");
await new Promise((r) => setTimeout(r, 1000));
}
}Result types
The SDK returns plain objects mirroring the API responses. Key fields (all *_at are ISO-8601 UTC strings; ids are UUIDs):
interface Space { id; name; tenant_id?; created_at?; updated_at? }
interface Folder { id; name; space_id?; parent_id?: string | null;
deleted_at?: string | null; created_at?; updated_at? }
type ScanStatus = "pending" | "clean" | "infected";
interface FileItem {
id;
original_name;
name?;
space_id?;
folder_id?: string | null;
mime?: string | null;
size?: number | null;
sha256?: string | null;
scan_status?: ScanStatus; // must be "clean" to share/serve
status?;
client_key?: string | null; // your logical key, if you set one
deleted_at?: string | null;
created_at?; updated_at?;
public_url?; // set when upload(..., { public: true })
}
interface Link { // returned by publicUrl()/signedUrl()
url; // https://files.fluidadmin.com/s/<token>
id;
permission;
expires_at: string | null; // null = permanent
}
interface Share { // returned by shares.list()
id; file_id; permission;
expires_at: string | null;
max_downloads: number | null;
download_count: number;
revoked: boolean;
has_password: boolean; // stored, NOT enforced at serve time
created_at?; updated_at?;
}
interface Quota {
bytes_used; bytes_limit; // limit -1 = unlimited
links_used; links_limit; // limit -1 = unlimited
}Error handling
Every non-2xx response is thrown as a typed error. Import the classes and branch on instanceof:
import {
FluidCloud,
AuthError, // 401 — missing/invalid/revoked key
QuotaExceededError, // 402 — over storage or share-link cap
PermissionError, // 403 — insufficient_scope or feature_not_in_plan
NotFoundError, // 404 — not found OR not yours (cross-tenant)
ConflictError, // 409 — file not ready (quarantine-until-clean)
ApiError, // any other status (400, 413, 422, 429, 5xx, ...)
} from "fluidcloud";
try {
const link = await fc.files.publicUrl(fileId);
console.log(link.url);
} catch (err) {
if (err instanceof ConflictError) {
// file is still being scanned — retry after it's clean
} else if (err instanceof QuotaExceededError) {
// over storage or share-link limit
} else if (err instanceof PermissionError) {
// key lacks the scope, or the plan doesn't include API access
} else if (err instanceof NotFoundError) {
// wrong id, or an id from another tenant
} else if (err instanceof AuthError) {
// rotate/replace the key
} else if (err instanceof ApiError) {
console.error(err.status, err.detail); // 400/413/422/429/5xx
throw err;
} else {
throw err;
}
}AuthError, QuotaExceededError, PermissionError, NotFoundError, and ConflictError all extend ApiError, so a single catch (err: unknown) { if (err instanceof ApiError) ... } covers every API failure. The error carries the HTTP status and the response body's detail.
A 429 (rate limited) surfaces as ApiError with status === 429; honor the Retry-After header before retrying. See Errors and Rate limits.
Full example
import {
FluidCloud,
ConflictError,
ApiError,
} from "fluidcloud";
import { readFile } from "node:fs/promises";
const fc = new FluidCloud({ apiKey: process.env.FLUIDCLOUD_API_KEY! });
// 1. A space + folder to organize into.
const [space] = await fc.spaces.list();
const folder = await fc.folders.create("job-7", space.id);
// 2. Upload raw bytes, tagged with your own logical key.
const bytes = await readFile("./out.png");
let file = await fc.files.upload(bytes, space.id, {
name: "out.png",
folderId: folder.id,
clientKey: "results/job-7/out.png",
});
// 3. Wait until the virus scan clears.
while (file.scan_status !== "clean") {
if (file.scan_status === "infected") throw new Error("failed scan");
await new Promise((r) => setTimeout(r, 1000));
file = await fc.files.get(file.id);
}
// 4. Mint a permanent public hotlink and a 7-day signed link.
const hotlink = await fc.files.publicUrl(file.id);
const signed = await fc.files.signedUrl(file.id, { expiresInDays: 7 });
console.log(hotlink.url); // https://files.fluidadmin.com/s/...
console.log(signed.url, "expires", signed.expires_at);
// 5. Later, resolve by your client_key — no FluidCloud id needed.
const again = await fc.files.resolve("results/job-7/out.png");
// 6. Check usage.
const q = await fc.quota.usage();
console.log(`${q.bytes_used}/${q.bytes_limit} bytes, ${q.links_used}/${q.links_limit} links`);Notes & limits
- Direct-to-storage uploads. Bytes go straight to object storage over presigned URLs; the API only issues the ticket and records completion. Large files upload as multipart automatically.
- Scopes. A partner key carries a fixed scope set (
spaces,folders,files,sharesread/write, plusquota:read). A call lacking a required scope throwsPermissionError(insufficient_scope). See Authentication. - Rate limits. 600 requests/min per key by default, plus per-action caps (share links 120/min, upload starts 300/min). Over a limit →
ApiErrorwithstatus === 429and aRetry-Afterheader. See Rate limits. - Federation / cross-service delegation (acting as another user) is a first-party capability and is not available to partner API keys; those helpers are intentionally not part of your integration surface.
See also: Python SDK · Quickstart · Concepts · API reference.