API Reference — Uploads
Endpoints for getting bytes into FluidCloud. The API never proxies your file bytes: you ask FluidCloud for a presigned upload ticket, then PUT the bytes directly to storage from your own client. A short /complete call then registers the file. There is also a server-side URL import that fetches a remote file for you.
All routes are under the base URL https://api-cloud.fluidvip.com/api/v1 and are prefixed with /uploads. Every request must carry your API key — see authentication.md.
For the end-to-end walkthrough (single-PUT vs. multipart, the SDK upload() shortcut, and progress handling), read the Uploading guide.
| Method & path | Scope | Purpose |
|---|---|---|
POST /uploads/initiate | files:write | Reserve a file id + key, get presigned upload ticket(s) |
POST /uploads/complete | files:write | Finalize the upload and create the file record |
POST /uploads/import-url | files:write | Server-side fetch of a remote URL into a Space |
Quarantine-until-clean. Every uploaded or imported file is automatically virus-scanned. A new file lands with
scan_statuspendingandstatusdraft; it becomesscan_statusclean(andstatusadvances) only after the scan passes. A file must becleanbefore it can be shared or served — sharing a not-yet-clean file returns409. See Raw links and Shares.
The upload flow
A direct-to-storage upload is three steps:
POST /uploads/initiate— you describe the file (name, size, Space). FluidCloud reserves afile_idand a storagekey, then returns a presigned ticket. No file record exists yet.- You PUT the bytes to the presigned URL(s) returned by step 1 — straight to storage, not through this API.
POST /uploads/complete— you confirm the upload (echoing thefile_idandkey, plus multipart ETags if any). FluidCloud creates the file record, counts the bytes against your quota, and runs the scan.
The cutoff between single and multipart mode is 100 MB, and FluidCloud decides it for you from the size you send to /initiate:
- Below 100 MB →
mode: "single". One presignedPUTURL (upload_url). - 100 MB and above →
mode: "multipart". Anupload_id, apart_size, and one presigned URL per part (part_urls). Upload each part, collect itsETag, and pass all parts back to/complete.
Tip — let the SDK do it.
files.upload(Python) /files.upload(TypeScript) runs all three steps for you, picks single vs. multipart, uploads the parts, and returns the finished file. Use the raw endpoints below only when you need fine-grained control (e.g. your own resumable part scheduler). See Python SDK and TypeScript SDK.
POST /uploads/initiate
Reserve a file id and a quarantine storage key, and receive presigned upload ticket(s). No file record is created yet — that happens at /complete.
Scope: files:write
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
original_name | string | yes | The file's display name (min length 1). Used to derive the file extension. |
size | integer | yes | Exact byte size of the file (>= 0). Determines single vs. multipart and is checked against your storage quota. |
space_id | string (UUID) | yes | The Space the file will belong to. See Spaces. |
content_type | string | no | MIME type. In single mode this exact value is signed into the ticket — your PUT must send the same Content-Type header (or none, if you omit it), or storage rejects the PUT. |
folder_id | string (UUID) | no | Destination folder within the Space. Omit for the Space root. See Folders. |
Response — InitiateResponse
| Field | Type | Notes |
|---|---|---|
file_id | string (UUID) | Reserved id. Echo it back to /complete. |
key | string | Server-built storage key under your tenant's quarantine prefix. Echo it back to /complete (it is re-validated, never trusted blindly). |
mode | "single" | "multipart" | Which flow to use. |
content_type | string | null | The Content-Type that was signed (single mode). Send it verbatim on your PUT. null = none signed. |
upload_url | string | null | Single mode only. Presigned PUT URL for the whole file. |
upload_id | string | null | Multipart mode only. Pass back to /complete. |
part_size | integer | null | Multipart mode only. Byte size of each non-final part. Slice the file into parts of exactly this size (the last part may be smaller). |
part_urls | array | null | Multipart mode only. List of { part_number, url }. PUT each part to its url. |
Status codes
- 200 — ticket issued.
- 400 — bad input (e.g. malformed
space_id/folder_id). - 401 — missing/invalid/revoked key.
- 402 —
detailisquota_exceeded. Storingsizemore bytes would exceed your storage cap; nothing is signed. Check Quota. - 403 —
insufficient_scope(key lacksfiles:write), or you don't have access to the target folder. - 404 — the
space_id/folder_idis not found or not yours. - 413 — the file is too large to upload (would exceed the multipart part cap).
- 422 — request body validation failed.
- 429 — too many
/initiatecalls; the per-action cap is 300/min. ARetry-Afterheader is returned. See Rate limits.
Examples
curl (single, < 100 MB)
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": "report.pdf",
"content_type": "application/pdf",
"size": 824113,
"space_id": "8a1f0e2c-1234-4abc-9def-0123456789ab"
}'Response:
{
"file_id": "0c3f9b7a-aaaa-4bbb-8ccc-111122223333",
"key": "…/incoming/…/report.pdf",
"mode": "single",
"content_type": "application/pdf",
"upload_url": "https://…storage…/…?X-Amz-Signature=…",
"upload_id": null,
"part_size": null,
"part_urls": null
}Then PUT the bytes (note the matching Content-Type):
curl -X PUT "https://…storage…/…?X-Amz-Signature=…" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdfPython (fluidcloud)
from fluidcloud import FluidCloud
fc = FluidCloud(api_key="fck_live_...")
ticket = fc.uploads.initiate(
original_name="report.pdf",
content_type="application/pdf",
size=824113,
space_id="8a1f0e2c-1234-4abc-9def-0123456789ab",
)
print(ticket.mode, ticket.upload_url)
# In practice, prefer the one-call helper, which handles initiate -> PUT -> complete:
file = fc.files.upload("report.pdf", space_id="8a1f0e2c-1234-4abc-9def-0123456789ab")
print(file.id, file.scan_status)TypeScript (fluidcloud)
import { FluidCloud } from "fluidcloud";
const fc = new FluidCloud({ apiKey: "fck_live_..." });
const ticket = await fc.uploads.initiate({
original_name: "report.pdf",
content_type: "application/pdf",
size: 824113,
space_id: "8a1f0e2c-1234-4abc-9def-0123456789ab",
});
console.log(ticket.mode, ticket.upload_url);
// Or the one-call helper (initiate -> PUT -> complete):
const file = await fc.files.upload(blob, {
space_id: "8a1f0e2c-1234-4abc-9def-0123456789ab",
});
console.log(file.id, file.scan_status);POST /uploads/complete
Finalize the upload — completing the multipart upload (if any) and creating the file record. The bytes are counted against your storage quota in the same transaction, then the file is scanned.
Scope: files:write
You must echo the file_id and key from /initiate. The key is re-validated against your tenant prefix; a key that doesn't belong to you is rejected.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
file_id | string (UUID) | yes | The file_id from /initiate. |
key | string | yes | The key from /initiate. |
original_name | string | yes | Display name (min length 1). |
size | integer | yes | Final byte size (>= 0). |
space_id | string (UUID) | yes | Same Space as /initiate. |
upload_id | string | conditional | Multipart only. The upload_id from /initiate. |
parts | array | conditional | Multipart only. List of { "PartNumber": <int >= 1>, "ETag": "<etag>" }, one per uploaded part. Required when upload_id is present. |
mime | string | no | MIME type to store as metadata. |
sha256 | string | no | Hex SHA-256 of the file, stored as metadata. |
folder_id | string (UUID) | no | Destination folder. Re-checked here. |
client_key | string | no | Your own logical key for this file (e.g. results/job-7/out.png). Lets you fetch it later by your key via files.resolve / GET /files/resolve, without storing FluidCloud's id. See note below. |
Response — FileResponse
The created file record:
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The file's FluidCloud id. |
tenant_id | string (UUID) | Your tenant. |
space_id | string (UUID) | |
folder_id | string (UUID) | null | |
source_app | string | null | Originating product tag. |
client_key | string | null | Your logical key, if you sent one. |
original_name | string | |
mime | string | null | |
size | integer | null | |
sha256 | string | null | |
scan_status | string | pending right after completion; becomes clean (or infected) once the scan finishes. |
status | string | draft while quarantined; advances once scanned clean. |
created_by | string | null |
About
client_key. A logical key maps to exactly one live object. If you complete a new upload with the sameclient_key(within the same Space context), the previous file with that key is superseded (soft-deleted) sofiles.resolvedeterministically returns the newest one. This mirrors object-storage semantics: writing the same key replaces it. See Files.
Status codes
- 200 — file record created (scan may still be in progress; check
scan_status). - 400 — bad input: malformed id, a key that doesn't match the tenant/Space prefix, or a multipart completion missing
parts. - 401 — missing/invalid/revoked key.
- 403 —
insufficient_scope, or you don't have access to the target folder. - 404 — the
space_id/folder_idis not found or not yours. - 402 —
detailisquota_exceeded(the byte reservation would exceed your storage cap). - 422 — request body validation failed.
- 429 — rate limited (the default 600/min API ceiling). A
Retry-Afterheader is returned.
Examples
curl (single — no upload_id/parts)
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": "0c3f9b7a-aaaa-4bbb-8ccc-111122223333",
"key": "…/incoming/…/report.pdf",
"original_name": "report.pdf",
"mime": "application/pdf",
"size": 824113,
"space_id": "8a1f0e2c-1234-4abc-9def-0123456789ab",
"client_key": "results/job-7/report.pdf"
}'curl (multipart — with upload_id and parts)
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": "0c3f9b7a-aaaa-4bbb-8ccc-111122223333",
"key": "…/incoming/…/movie.mp4",
"original_name": "movie.mp4",
"mime": "video/mp4",
"size": 734003200,
"space_id": "8a1f0e2c-1234-4abc-9def-0123456789ab",
"upload_id": "2~abc123…",
"parts": [
{ "PartNumber": 1, "ETag": "\"d41d8cd9…\"" },
{ "PartNumber": 2, "ETag": "\"0cc175b9…\"" }
]
}'Python (fluidcloud)
file = fc.uploads.complete(
file_id=ticket.file_id,
key=ticket.key,
original_name="report.pdf",
mime="application/pdf",
size=824113,
space_id="8a1f0e2c-1234-4abc-9def-0123456789ab",
client_key="results/job-7/report.pdf",
# multipart only:
# upload_id=ticket.upload_id,
# parts=[{"PartNumber": 1, "ETag": etag1}, ...],
)
print(file.id, file.scan_status, file.status)TypeScript (fluidcloud)
const file = await fc.uploads.complete({
file_id: ticket.file_id,
key: ticket.key,
original_name: "report.pdf",
mime: "application/pdf",
size: 824113,
space_id: "8a1f0e2c-1234-4abc-9def-0123456789ab",
client_key: "results/job-7/report.pdf",
// multipart only:
// upload_id: ticket.upload_id,
// parts: [{ PartNumber: 1, ETag: etag1 }, /* ... */],
});
console.log(file.id, file.scan_status, file.status);POST /uploads/import-url
Have FluidCloud fetch a file from a remote http(s) URL and store it in a Space — no presign/PUT round-trip on your side. The fetch is size-capped and runs through the same quarantine-and-scan pipeline as a direct upload.
Scope: files:write
The remote source must be a single, directly-downloadable file. The import is capped at 50 GB; the source Content-Type is kept only as display metadata (the real file type is determined by the scan). Importing from a Google Drive folder link is not available to API keys.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Public http(s) URL of the file (min length 1). |
space_id | string (UUID) | yes | Destination Space. |
folder_id | string (UUID) | no | Destination folder. |
name | string | no | Override the stored filename. If omitted, the name is derived from the URL path (or the response's Content-Disposition). |
Response — FileResponse
Same shape as /uploads/complete (see above). On success the file exists and has been scanned; check scan_status for the verdict.
Status codes
- 200 — file imported.
- 400 — bad input: unresolvable/non-public host, disallowed scheme, an invalid redirect, an empty body, or an HTTP error from the source. (For safety, only public hosts are reachable; internal/private addresses are refused.)
- 401 — missing/invalid/revoked key.
- 402 —
detailisquota_exceeded(the import would exceed your storage cap; refused before or after fetch). - 403 —
insufficient_scope, or you don't have access to the target folder. - 404 — the
space_id/folder_idis not found or not yours. - 413 — the file exceeds the 50 GB import limit.
- 422 — request body validation failed.
- 429 — too many imports started; the per-action cap is 30/min. A
Retry-Afterheader is returned.
Examples
curl
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/photo.jpg",
"space_id": "8a1f0e2c-1234-4abc-9def-0123456789ab",
"name": "photo.jpg"
}'Python (fluidcloud)
file = fc.uploads.import_url(
url="https://example.com/assets/photo.jpg",
space_id="8a1f0e2c-1234-4abc-9def-0123456789ab",
name="photo.jpg",
)
print(file.id, file.scan_status)TypeScript (fluidcloud)
const file = await fc.uploads.importUrl({
url: "https://example.com/assets/photo.jpg",
space_id: "8a1f0e2c-1234-4abc-9def-0123456789ab",
name: "photo.jpg",
});
console.log(file.id, file.scan_status);Error handling
Every error is an HTTP status with a JSON body whose top key is detail (a string, or a structured object for 403). The SDKs map statuses to typed errors: QuotaExceededError (402), PermissionError (403), NotFoundError (404), AuthError (401), ConflictError (409), and ApiError for the rest. For the full model, see Errors.
See also
- Uploading guide — the full single vs. multipart walkthrough and the SDK
upload()shortcut. - Files — read, list, resolve-by-
client_key, and manage files after upload. - Shares and Raw links — turn a scanned-clean file into a public link.
- Quota — read your storage usage and limits.
- Rate limits — the 600/min default plus the 300/min initiate and 30/min import caps.