Uploading files
FluidCloud uploads go directly from your client to storage — the API never proxies your bytes. You ask the API for a presigned upload ticket, PUT the bytes straight to the storage URL it hands back, then tell the API the upload is done. Every uploaded file is then automatically scanned and held in quarantine until it is confirmed clean.
This guide covers the two ways to get bytes into FluidCloud:
- Direct upload — your client uploads bytes you already hold (
/uploads/initiate→ presignedPUT(s) →/uploads/complete). Single-PUT for files under 100 MB, multipart for larger. - URL import — FluidCloud fetches a single file from a public URL for you (
/uploads/import-url).
Both paths require the files:write scope, which your partner key carries. For the exact request/response shapes see the Uploads reference. Once a file is clean, turn it into a raw link with the Raw links guide.
Just want it done? Use an SDK. Both
fluidcloud(Python) andfluidcloud(TypeScript) collapse the entire three-step direct-upload flow — including multipart, ETags, and the sha256 — into a singlefiles.upload(...)call. Jump to SDK one-liner.
Before you start
- You upload into a Space (and optionally a Folder). Create a Space first (see Organizing files) and pass its
idon every upload. - All ids are UUID strings; all timestamps are ISO-8601 UTC.
- Quota is enforced. If an upload would push you over your storage pool,
/uploads/initiatereturns402withdetailequal toquota_exceededbefore it signs anything — so you never waste an upload you can't keep. Check your headroom first with the quota endpoint. - Quarantine until clean. A freshly uploaded file lands with
scan_statusofpendingand is not servable or shareable until the scan finishes and setsscan_statustoclean. Sharing or serving a not-yet-clean file returns409. See Quarantine and the scan. - Rate limits apply per key. Starting uploads is capped at 300/min and URL imports at 30/min, on top of the global 600/min. Over a cap you get
429with aRetry-Afterheader. See Rate limits.
The direct-upload flow
A direct upload is always three steps:
1. POST /uploads/initiate -> get a file_id, a key, and presigned PUT URL(s)
2. PUT <presigned url(s)> -> send the bytes straight to storage (no API key)
3. POST /uploads/complete -> finalize; the File row is created + scannedNo File row exists until step 3 — /uploads/initiate only reserves an id and a quarantine key and signs the ticket(s). The single vs multipart mode is decided by file size:
| File size | Mode | What /initiate returns |
|---|---|---|
| < 100 MB | single | one upload_url + the content_type it signed |
| ≥ 100 MB | multipart | an upload_id, a part_size, and a part_urls[] list |
1. Initiate
POST /uploads/initiate with the file's metadata. You must send the real byte size — the server uses it to pick the mode, lay out multipart parts, and run the quota gate.
curl -X POST https://api-cloud.fluidvip.com/api/v1/uploads/initiate \
-H "X-API-Key: fck_live_..." \
-H "Content-Type: application/json" \
-d '{
"original_name": "logo.png",
"content_type": "image/png",
"size": 48213,
"space_id": "8a1f...space-uuid",
"folder_id": null
}'A single-mode response:
{
"file_id": "f7c2...uuid",
"key": "…/incoming/…/logo.png",
"mode": "single",
"content_type": "image/png",
"upload_url": "https://<storage-host>/...&X-Amz-Signature=...",
"upload_id": null,
"part_size": null,
"part_urls": null
}You will echo file_id and key back verbatim at the complete step.
2a. PUT the bytes — single (under 100 MB)
PUT the whole file body to upload_url. This is not an API call — do not send your API key; the signature is in the URL.
Content-Type gotcha. The presigned single-PUT URL is signed with the
content_typeyou sent to/initiate, and the response echoes the exact value back ascontent_type. YourPUTmust send that sameContent-Typeheader verbatim (or noContent-Typeat all if it wasnull). A mismatch makes storage reject thePUTwith403 SignatureDoesNotMatch. The SDKs handle this for you.
curl -X PUT "https://<storage-host>/...&X-Amz-Signature=..." \
-H "Content-Type: image/png" \
--data-binary @logo.pngThe PUT response carries an ETag header. You do not need it for a single upload, but you do for multipart (below).
2b. PUT the parts — multipart (100 MB and over)
A multipart /initiate response gives you a part_size and an ordered part_urls[] of { part_number, url }. Slice your file into part_size-byte chunks and PUT each chunk to its matching part URL, in order. Every non-final part must be exactly part_size bytes; the final part may be smaller.
Each part PUT returns an ETag header — capture it, paired with its part_number. You hand the full { PartNumber, ETag } list back at the complete step so the server can assemble the object.
Multipart part PUTs are not Content-Type-signed (storage sets the type when the multipart upload is created), so you don't send a
Content-Typeon the part PUTs.
# For each part url returned by /initiate:
curl -X PUT "<part_url_for_part_1>" --data-binary @part-1.bin -D - -o /dev/null
# -> grab the ETag header from the response, pair it with PartNumber: 1If a file is so large that even the maximum part size can't keep it under the storage part cap, /initiate returns 413.
3. Complete
POST /uploads/complete, echoing file_id and key from step 1 and the same space_id / folder_id. Send size again, and the sha256 you computed over the bytes as you uploaded them (recommended — it lets the server record an integrity digest). For multipart, also send upload_id and the assembled parts list of { PartNumber, ETag }.
# Single upload:
curl -X POST https://api-cloud.fluidvip.com/api/v1/uploads/complete \
-H "X-API-Key: fck_live_..." \
-H "Content-Type: application/json" \
-d '{
"file_id": "f7c2...uuid",
"key": "…/incoming/…/logo.png",
"original_name": "logo.png",
"mime": "image/png",
"size": 48213,
"sha256": "9f86d0...",
"space_id": "8a1f...space-uuid",
"folder_id": null
}'// Multipart: also include upload_id + parts
{
"file_id": "…", "key": "…", "space_id": "…",
"original_name": "render.mp4", "mime": "video/mp4", "size": 734003200,
"sha256": "…",
"upload_id": "2~abc...",
"parts": [
{ "PartNumber": 1, "ETag": "\"d41d8cd...\"" },
{ "PartNumber": 2, "ETag": "\"e99a18c...\"" }
]
}/uploads/complete finalizes the multipart object (if any), creates the File row, reserves the bytes against your quota, and runs the automatic scan inline. It returns the stored file:
{
"id": "f7c2...uuid",
"tenant_id": "…",
"space_id": "8a1f...",
"folder_id": null,
"source_app": "fluidcloud",
"client_key": null,
"original_name": "logo.png",
"mime": "image/png",
"size": 48213,
"sha256": "9f86d0...",
"scan_status": "clean",
"status": "pending",
"created_by": "…"
}If the scan came back clean, scan_status is clean and the file is ready to share. If it is still pending, poll the file (GET /files/{id}) until scan_status becomes clean before you try to share it.
The SDK one-liner
You almost never need to drive the three steps by hand. files.upload(...) does the whole presign → direct-PUT → complete dance — picking single vs multipart, computing the sha256, and capturing ETags — in one call.
Python (pip install fluidcloud):
from fluidcloud import FluidCloud
fc = FluidCloud(api_key="fck_live_...")
# Path, bytes, or an open binary file all work:
asset = fc.files.upload("logo.png", space_id=sp.id)
print(asset.id, asset.scan_status)
# Into a folder, with an explicit name / content type:
asset = fc.files.upload(
open("render.mp4", "rb"), # >= 100 MB -> multipart, transparently
space_id=sp.id,
folder_id=folder.id,
name="hero-render.mp4",
content_type="video/mp4",
)TypeScript (npm install fluidcloud):
import { FluidCloud } from "fluidcloud";
const fc = new FluidCloud({ apiKey: "fck_live_..." });
const asset = await fc.files.upload("logo.png", spaceId, {
folderId, // optional
contentType: "image/png", // optional; otherwise guessed from the name
});
console.log(asset.id, asset.scan_status);public: true — upload and get a hotlink in one step
Pass public to also mint a permanent public raw link and set it on the result, so a single call gives you both the stored file and a hotlinkable URL (an <img src>-ready link under https://files.fluidadmin.com/s/<token>). This hides the separate share step entirely.
asset = fc.files.upload("logo.png", space_id=sp.id, public=True)
print(asset.public_url) # https://files.fluidadmin.com/s/<token>const asset = await fc.files.upload("logo.png", spaceId, { public: true });
console.log(asset.public_url); // https://files.fluidadmin.com/s/<token>The public link is minted after completion, so it only succeeds once the file scans clean. For everything links can do — expiring links, listing, revoking — see Raw links.
Upload under your own key (client_key)
When you complete an upload you may attach your own logical key — client_key, e.g. results/job-7/out.png. Later you can fetch that exact file by the same client_key without ever storing FluidCloud's file_id.
fc.files.upload("out.png", space_id=sp.id, client_key="results/job-7/out.png")
# Later, anywhere in your system:
asset = fc.files.resolve("results/job-7/out.png")
print(asset.id, asset.public_url)await fc.files.upload("out.png", spaceId, { clientKey: "results/job-7/out.png" });
const asset = await fc.files.resolve("results/job-7/out.png");A client_key maps to exactly one live file (your tenant). Re-uploading to the same client_key supersedes the previous file — the old one is moved to trash and resolve deterministically returns the newest. If there is no live match, resolve raises NotFoundError (404). This is ideal when your own system already has stable identifiers and you don't want to keep a key→id map.
Importing a file from a URL
Instead of uploading bytes yourself, you can hand FluidCloud a public URL and have it fetch and store the file server-side. The bytes run through the same pipeline as a normal upload: stored in quarantine, counted against quota, then scanned.
POST /uploads/import-url:
curl -X POST https://api-cloud.fluidvip.com/api/v1/uploads/import-url \
-H "X-API-Key: fck_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/assets/banner.jpg",
"space_id": "8a1f...space-uuid",
"folder_id": null,
"name": "banner.jpg"
}'The SDKs don't wrap this route, so call it directly through the SDK's HTTP client (the API key is still attached automatically) or with your own HTTP client:
# Python: drive the raw request through the SDK's authenticated client.
asset = fc._request("POST", "/uploads/import-url", json={
"url": "https://example.com/assets/banner.jpg",
"space_id": sp.id,
"name": "banner.jpg", # optional; otherwise derived from the URL path
})
print(asset["id"], asset["scan_status"])// TypeScript: a plain fetch with your API key.
const res = await fetch(
"https://api-cloud.fluidvip.com/api/v1/uploads/import-url",
{
method: "POST",
headers: {
"X-API-Key": "fck_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/assets/banner.jpg",
space_id: spaceId,
name: "banner.jpg",
}),
},
);
const asset = await res.json();The response is the same File shape returned by /uploads/complete.
What URL import does and doesn't do:
- Filename comes from
nameif you set it, otherwise the response'sContent-Disposition, otherwise the URL path's basename. - SSRF-safe. Only
http/httpspublic hosts are allowed. The fetch resolves the host, refuses any private / loopback / link-local / reserved address, and pins the connection to the validated IP — so a redirect or DNS rebind can't be steered onto an internal address. Each redirect hop is re-validated; too many redirects, an unreachable or non-public host, an HTTP error from the source, or an empty body all return400. - Size-capped at 50 GB. A source larger than the import limit returns
413. The bytes stream straight into storage a chunk at a time, so the cap bounds time and bandwidth, not server memory. - Quota-gated. If the imported file won't fit your storage pool you get
402withdetailofquota_exceeded; an object that overflows the quota mid-stream is dropped, not kept. - Single file only. This endpoint imports one file. Importing a whole Google Drive folder is not available to API keys — pass a single direct file URL instead.
Quarantine and the scan
Every file — uploaded or imported — is automatically scanned on completion. Until the scan finishes it sits in quarantine:
- A new file is created with
scan_statusofpending. - When the scan passes,
scan_statusbecomescleanand the file becomes shareable/servable. - A file that is not
cleancannot be shared or served — attempting to create a share link or serve it returns409("not ready"). This is the quarantine-until-clean rule.
In practice: after files.upload(...) or an import, check scan_status on the returned file. If it's already clean, share immediately. If it's pending, re-fetch with GET /files/{id} until it flips to clean, then create the link. See Raw links for the sharing step and Error handling for the full 409 semantics.
Errors you may hit
| Status | Meaning | What to do |
|---|---|---|
400 | Bad input — malformed id, bad import URL, disallowed/non-public import host | Fix the request body. |
401 | Missing / invalid / revoked API key | Check the X-API-Key header. See Authentication. |
402 | detail is quota_exceeded — over your storage pool | Free space or read quota. |
403 | Structured detail: insufficient_scope (key lacks files:write) or feature_not_in_plan (subscription doesn't include API access) | Verify your key's scopes / plan. |
403 (from storage, not the API) | SignatureDoesNotMatch on a single PUT | Send the exact Content-Type the ticket signed (the content-type gotcha). |
409 | The file isn't scanned clean yet | Wait for scan_status to become clean. |
413 | File too large to upload, or import over the 50 GB cap | Reduce size; very large files use multipart automatically. |
422 | Validation error on the request body | Check field types/shapes. |
429 | Rate limited (300/min uploads, 30/min imports, 600/min overall) | Honor the Retry-After header. See Rate limits. |
The SDKs map these to typed errors: AuthError (401), QuotaExceededError (402), PermissionError (403), NotFoundError (404), ConflictError (409), and ApiError for the rest. See the full error model.
See also
- Uploads API reference — every field on
/uploads/initiate,/uploads/complete, and/uploads/import-url. - Raw links — turn a clean file into a shareable
https://files.fluidadmin.com/s/<token>URL. - Quota and usage — read your storage headroom before you upload.
- Organizing files — Spaces and Folders to upload into.