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:

FieldRequiredDescription
fileyesThe file to upload. The filename and content type are taken from this part.
collection_idnoAdd 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://…"
}
FieldTypeDescription
idstringFile id — stable across versions.
statusstringprocessing, ready, or failed.
original_namestringFilename as uploaded.
mime_typestringDetected content type.
size_bytesnumberFile size in bytes.
widthnumber | nullPixel width — null until processing finishes.
heightnumber | nullPixel height — null until processing finishes.
taken_atstring | nullCapture time from EXIF (ISO 8601), if present.
created_atstringUpload time (ISO 8601).
thumbnail_urlstring | nullSigned 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.