Uploading files
Files up to 100 MB upload with a single multipart POST. Larger files (up to 2 GB) use the resumable tus endpoint instead. Both paths end in the same place: the file lands in your library and runs through the standard processing pipeline (thumbnails, detection, captioning, and so on).
Requires the upload scope.
POST /api/v1/files
multipart/form-data body with these fields:
| Field | Required | Description |
|---|---|---|
file | yes | The file to upload. The filename and content type are taken from this part. |
collection_id | no | Add the file to a collection you have access to. An unknown or inaccessible id stores the file library-only. |
Accepted content types: any image/* or video/*, plus application/pdf
and text/plain. Anything else is rejected with 415 unsupported_media_type.
A Content-Length header is required (curl and every mainstream HTTP client
send it automatically).
curl -X POST https://your-host/api/v1/files \
-H "Authorization: Bearer na_your_key" \
-F "file=@sunset.jpg" \
-F "collection_id=0198c9a0-7d3e-7c41-b7a2-3f9d1c2e4a5b"
The same request from Node.js:
import { readFile } from "node:fs/promises"
const form = new FormData()
form.append(
"file",
new Blob([await readFile("sunset.jpg")], { type: "image/jpeg" }),
"sunset.jpg"
)
const res = await fetch("https://your-host/api/v1/files", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
body: form,
})
const { id } = await res.json()
Response
202 Accepted — the upload is stored and queued; processing is asynchronous:
{
"id": "0198c9a0-7d3e-7c41-b7a2-3f9d1c2e4a5b",
"version_id": "0198c9a0-8a12-7e55-9c01-d4b6f7a8e9c0",
"status": "processing"
}
Files over the 100 MB cap fail with 413 payload_too_large; when the team's
storage quota is full the response is 507 quota_exceeded.
GET /api/v1/files/:id
Returns the current state and metadata of a file. Works with either the
upload or the search scope, so it doubles as the poll target after an
upload and as a detail fetch for search results.
curl https://your-host/api/v1/files/0198c9a0-7d3e-7c41-b7a2-3f9d1c2e4a5b \
-H "Authorization: Bearer na_your_key"
{
"id": "0198c9a0-7d3e-7c41-b7a2-3f9d1c2e4a5b",
"status": "ready",
"original_name": "sunset.jpg",
"mime_type": "image/jpeg",
"size_bytes": 2481931,
"width": 4000,
"height": 3000,
"taken_at": "2026-06-30T18:41:02.000Z",
"created_at": "2026-07-11T09:12:44.201Z",
"thumbnail_url": "https://…"
}
| Field | Type | Description |
|---|---|---|
id | string | File id — stable across versions. |
status | string | processing, ready, or failed. |
original_name | string | Filename as uploaded. |
mime_type | string | Detected content type. |
size_bytes | number | File size in bytes. |
width | number | null | Pixel width — null until processing finishes. |
height | number | null | Pixel height — null until processing finishes. |
taken_at | string | null | Capture time from EXIF (ISO 8601), if present. |
created_at | string | Upload time (ISO 8601). |
thumbnail_url | string | null | Signed thumbnail URL, null until the file is ready. |
thumbnail_url is a signed link valid for about an hour — fetch it when you
need it rather than storing it. Original file downloads are not part of v1.
An id that doesn't exist — or belongs to another team — returns
404 not_found.
Polling for readiness
Processing usually takes a few seconds per file. Poll with a modest interval:
async function waitUntilReady(id, apiKey) {
for (;;) {
const res = await fetch(`https://your-host/api/v1/files/${id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
const file = await res.json()
if (file.status !== "processing") return file
await new Promise((resolve) => setTimeout(resolve, 2000))
}
}
Keep the interval at a second or more — polling counts against the rate limit like any other request.