---
title: "Upload a file"
method: POST
path: "/v1/projects/{project_id}/files/upload"
tags: ["Files"]
---

# Upload a file

`POST /v1/projects/{project_id}/files/upload`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/files/upload`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Uploads a file to the server and stores it in the configured storage directory

## Response `201`

File uploaded successfully

- FileRecord: Stored file metadata
  - `id` string: Unique file identifier
  - `prefix` string: Directory of the file (the `path` without its last segment). Read-only — set it via `prefix` on write.
  - `filename` string: Original / download name and the key's leaf segment.
  - `path` string, nullable: Full key of the file within the project — `prefix` + `/` + `filename` (e.g. /images/logo.png). Read-only; unique per project; the file's identity and policy-SRN target.
  - `content_type` string, nullable: MIME type of the file
  - `size` integer, nullable: File size in bytes
  - `metadata` MetadataBag: Caller-owned annotations on a resource, stored as the object they were written as: the types a value was written with are the types a read returns, so a filter can ask an ordering question about a number. Unlike other body fields, keys are stored and returned verbatim in the casing supplied — they are not converted between snake_case and camelCase. No key is reserved, and that is the point: every piece of state the platform owns lives in its own typed column, so nothing written here reaches platform state. The platform never reads the bag — it is not an IAM context, not a policy input and not part of a prompt — which is what separates it from a tag bag.
  - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
  - `created_at` string, date-time: Creation timestamp
  - `updated_at` string, date-time: Last update timestamp

## Other responses

- `400`: Missing file or invalid project
- `401`: Missing or invalid credentials.
- `403`: The credential is scoped to a different project, or the caller's role in the project does not carry this action.
- `409`: The project's `storage_bytes` quota is exceeded (`QUOTA_STORAGE_EXCEEDED`). Delete stored content, or raise the quota — no window reset clears a stored total, so no `Retry-After` is sent.
- `413`: The upload is over this deployment's byte ceiling (`UPLOAD_TOO_LARGE`; `FILE_UPLOAD_MAX_BYTES`, 25 MB by default). The request is refused while the body is still streaming, so nothing was stored.

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
