Skip to content

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 → presigned PUT(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) and fluidcloud (TypeScript) collapse the entire three-step direct-upload flow — including multipart, ETags, and the sha256 — into a single files.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 id on 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/initiate returns 402 with detail equal to quota_exceeded before 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_status of pending and is not servable or shareable until the scan finishes and sets scan_status to clean. Sharing or serving a not-yet-clean file returns 409. 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 429 with a Retry-After header. 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 + scanned

No 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 sizeModeWhat /initiate returns
< 100 MBsingleone upload_url + the content_type it signed
≥ 100 MBmultipartan 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.

bash
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:

json
{
  "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_type you sent to /initiate, and the response echoes the exact value back as content_type. Your PUT must send that same Content-Type header verbatim (or no Content-Type at all if it was null). A mismatch makes storage reject the PUT with 403 SignatureDoesNotMatch. The SDKs handle this for you.

bash
curl -X PUT "https://<storage-host>/...&X-Amz-Signature=..." \
  -H "Content-Type: image/png" \
  --data-binary @logo.png

The 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-Type on the part PUTs.

bash
# 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: 1

If 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 }.

bash
# 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
  }'
jsonc
// 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:

json
{
  "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):

python
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):

ts
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);

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.

python
asset = fc.files.upload("logo.png", space_id=sp.id, public=True)
print(asset.public_url)   # https://files.fluidadmin.com/s/<token>
ts
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 keyclient_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.

python
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)
ts
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:

bash
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
# 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"])
ts
// 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 name if you set it, otherwise the response's Content-Disposition, otherwise the URL path's basename.
  • SSRF-safe. Only http/https public 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 return 400.
  • 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 402 with detail of quota_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_status of pending.
  • When the scan passes, scan_status becomes clean and the file becomes shareable/servable.
  • A file that is not clean cannot be shared or served — attempting to create a share link or serve it returns 409 ("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

StatusMeaningWhat to do
400Bad input — malformed id, bad import URL, disallowed/non-public import hostFix the request body.
401Missing / invalid / revoked API keyCheck the X-API-Key header. See Authentication.
402detail is quota_exceeded — over your storage poolFree space or read quota.
403Structured 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 PUTSend the exact Content-Type the ticket signed (the content-type gotcha).
409The file isn't scanned clean yetWait for scan_status to become clean.
413File too large to upload, or import over the 50 GB capReduce size; very large files use multipart automatically.
422Validation error on the request bodyCheck field types/shapes.
429Rate 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.

FluidCloud API — part of the Fluidvip ecosystem.