Canonical page: https://docs.singlebase.io/files

# Files

Upload, organize, and serve files with `files.*` operations.

## Overview

**Storage** is a home for the files your users upload and the reports, documents, and images your agents produce. Singlebase keeps a record for each object. Uploads go straight from the client to storage, so large files never pass through your API requests.

**Key features:**
- Direct-to-storage uploads with signed upload tokens
- Batch uploads (up to 10 files by default)
- Logical buckets with allowed file types
- Public or private access, with time-limited signed URLs
- Optional knowledge indexing, so uploaded documents become context for agents and chat
- Profile photo uploads

**Use cases:** Avatars, attachments, agent-generated reports, knowledge documents for [Chat with Documents](/chat.md#attachments)

**On your website:** The [File Uploader](https://singlebase.io/components/file-uploader/) web component runs this upload flow for you.

---

**Access:** File storage operations require a signed-in user (bearer token), or a server, root, or permitted agent key. Operations on an existing file check that the user owns it. Admin callers can act on any file.

## Operations

| Operation | Description |
|---|---|
| `files.initiate_upload` | Get signed upload credentials |
| `files.complete_upload` | Verify uploaded objects and create file records |
| `files.fail_upload` | Report a failed upload |
| `files.query` | Query file records |
| `files.get_signed_url` | Get a temporary download URL |
| `files.update` | Update metadata or public access |
| `files.reprocess` | Retry ingestion or resume failed indexing |
| `files.delete` | Delete one or more files and their records |

---

## Upload Workflow

Uploading takes three steps:

1. **Initiate** - Call `files.initiate_upload`. Keep each `id` and `upload_token`.
2. **Upload** - `POST` the file to the returned storage `url` with the returned `fields`.
3. **Complete** - Call `files.complete_upload` with the same `id` and `upload_token`. If the upload failed, call `files.fail_upload` instead.

**Requirements:**
- File storage is enabled for the project.
- The bucket exists, is enabled, and allows the file's type.
- Files are between 100 bytes and 10 MB (10,485,760 bytes).
- Upload credentials and tokens expire after 30 minutes (1800 seconds).

**File types:**

| Type | Extensions |
|---|---|
| `IMAGE` | `png`, `gif`, `jpeg`, `jpg`, `webp` |
| `DOCUMENT` | `txt`, `md`, `rtf`, `tex`, `pdf`, `pptx`, `docx`, `xlsx`, `odp`, `odt`, `ods`, `epub` |
| `DATA` | `csv`, `json`, `xml`, `html`, `htm` |

Each bucket lists the types it accepts.

### files.initiate_upload

```json
{
  "operation": "files.initiate_upload",
  "payload": {
    "files": [
      {
        "filename": "report.pdf",
        "content_type": "application/pdf",
        "bucket": "default",
        "public_read": false,
        "metadata": { "category": "reports" }
      }
    ]
  }
}
```

**Parameters (each item in `files`):**

| Field | Required | Description |
|---|---|---|
| `filename` | Yes | File name with an allowed extension |
| `content_type` | No | MIME type |
| `bucket` | No | Logical bucket name. Default `"default"` |
| `new_name` | No | Rename the stored file |
| `public_read` | No | Make the file publicly readable. Default `false` |
| `metadata` | No | Your own data. Alias `info` |
| `options` | No | Upload options, such as `{"profile_photo": true}` |

`files` is always an array, even for one file. The batch limit defaults to 10.

**Response:**
```json
{
  "data": [
    {
      "id": "7b21f51e730d40d0b0e373ef720e43d5",
      "upload": {
        "url": "https://storage.example.com/",
        "fields": {
          "key": "project/7b21f51e730d40d0b0e373ef720e43d5.pdf",
          "Content-Type": "application/pdf",
          "policy": "<POLICY>",
          "x-amz-signature": "<SIGNATURE>"
        }
      },
      "upload_token": "<UPLOAD_TOKEN>"
    }
  ]
}
```

Results keep the input order. Send every `fields` entry unchanged. Do not decode or modify `upload_token`. Initiation does not create a file record.

### Upload to storage

This browser example uses the [`singlebase` helper](/api.md#_4-a-reusable-request-helper). `file` is a browser `File`.

```javascript
// 1. Initiate
const init = await singlebase('files.initiate_upload', {
  files: [{ filename: file.name, content_type: file.type }]
}, idToken);

const item = init.data[0];

// 2. Upload directly to storage
const form = new FormData();
for (const [name, value] of Object.entries(item.upload.fields)) {
  form.append(name, value);
}
form.append('file', file); // the file goes last

const uploaded = await fetch(item.upload.url, { method: 'POST', body: form });

// 3. Complete, or report the failure
if (!uploaded.ok) {
  await singlebase('files.fail_upload', {
    files: [{ id: item.id, upload_token: item.upload_token, error: `HTTP ${uploaded.status}` }]
  }, idToken);
  throw new Error('Upload failed');
}

const done = await singlebase('files.complete_upload', {
  files: [{ id: item.id, upload_token: item.upload_token }]
}, idToken);

console.log(done.data[0].url);
```

Let the browser set the multipart `Content-Type`. Do not send Singlebase headers to the storage URL.

### files.complete_upload

```json
{
  "operation": "files.complete_upload",
  "payload": {
    "files": [
      { "id": "7b21f51e730d40d0b0e373ef720e43d5", "upload_token": "<UPLOAD_TOKEN>" }
    ]
  }
}
```

**Response:**
```json
{
  "data": [
    {
      "id": "7b21f51e730d40d0b0e373ef720e43d5",
      "owner_id": "user_123",
      "filename": "report.pdf",
      "filesize": 248311,
      "content_type": "application/pdf",
      "fileclass": "DOCUMENT",
      "file_extension": "pdf",
      "bucket": "default",
      "status": "completed",
      "acl": "private",
      "metadata": { "category": "reports" },
      "url": "https://storage.example.com/project/7b21f51e730d40d0b0e373ef720e43d5.pdf",
      "object_uri": "s3://app-files/project/7b21f51e730d40d0b0e373ef720e43d5.pdf",
      "object_key": "project/7b21f51e730d40d0b0e373ef720e43d5.pdf"
    }
  ]
}
```

The response is abbreviated. Completion checks the token, the object's existence, size, and content type, then creates the record. Calling it again with the same token returns the same record.

A private file's `url` is not directly readable. Use [`files.get_signed_url`](#files-get-signed-url).

### files.fail_upload

```json
{
  "operation": "files.fail_upload",
  "payload": {
    "files": [
      {
        "id": "7b21f51e730d40d0b0e373ef720e43d5",
        "upload_token": "<UPLOAD_TOKEN>",
        "error": { "code": "UPLOAD_FAILED", "message": "Connection lost" }
      }
    ]
  }
}
```

Each item requires `id`, `upload_token`, and `error`. Returns `[{"id": "...", "change": "error"}]`. No file record is created.

**Batch behavior:** Upload batches run concurrently and are not transactional. Some files can complete while others fail.

### Profile photos

Set `options.profile_photo: true` on an image upload. Completing it also sets the owner's `profile_photo`.

```json
{
  "operation": "files.initiate_upload",
  "payload": {
    "files": [
      {
        "filename": "avatar.jpg",
        "content_type": "image/jpeg",
        "bucket": "avatars",
        "public_read": true,
        "options": { "profile_photo": true }
      }
    ]
  }
}
```

---

## files.query

Query file records.

```json
{
  "operation": "files.query",
  "payload": {
    "owner_id": "user_123",
    "content_type": "application/pdf",
    "status": "completed",
    "page": 1,
    "per_page": 20
  }
}
```

**Parameters (all optional):**

| Field | Description |
|---|---|
| `bucket` | Logical bucket |
| `status` | Record status, such as `completed` |
| `owner_id`, `owner_type` | Owner |
| `content_type` | MIME type |
| `storage_backend` | Storage backend |
| `search` | Text search. Alias `q` |
| `filter` | Additional [filter](/queries.md) |
| `page`, `per_page` | Defaults `1` and `20` |
| `limit`, `offset` | Offset pagination |

Returns file records in `data` and pagination in `meta.pagination`. To get one record, use `filter: {"id": "<FILE_ID>"}` and `limit: 1`.

::: warning
`files.query` automatically scopes signed-in users to their own files. Another payload-level `owner_id` or a non-user `owner_type` returns `OWNER_ID_MISMATCH`. Server and agent keys can query across owners or choose a payload-level `owner_id`. Never put `owner_id` or `owner_type` inside `filter`: that returns `INVALID_OWNER_ID_FILTER`.
:::

Query results are raw file records (for example `size_bytes` instead of `filesize`). They do not have exactly the same shape as upload results.

---

## files.get_signed_url

Get a temporary URL for a private file.

```json
{
  "operation": "files.get_signed_url",
  "payload": {
    "id": "7b21f51e730d40d0b0e373ef720e43d5",
    "signed_ttl": 3600
  }
}
```

**Response:**
```json
{
  "data": { "url": "https://storage.example.com/...&X-Amz-Signature=..." }
}
```

Send `id`, or an `s3://` object URI in `url`. `signed_ttl` defaults to 1800 seconds and is limited to 1 to 604800 seconds (7 days). Ownership is checked.

| `variant` | Object signed |
|---|---|
| `original` (default) | Original uploaded file |
| `markdown` | Stored `metadata.md_file` from DocLLM conversion |
| `docv` | Stored `metadata.docv_file`, the full `.docv.json` result |

For example, use `{"id": "file_123", "variant": "markdown", "signed_ttl": 3600}`
to open the converted Markdown. All variants require the owner or an admin and
use the same response and TTL rules. Agent keys need `files.read`. Conversion
URLs are signed from the file's stored metadata; callers cannot supply their path.
Missing Markdown returns `404 FILE_MARKDOWN_NOT_FOUND`; missing JSON returns
`404 FILE_DOCV_NOT_FOUND`.

A ready conversion saves `src_file`, `src_bytes`, `md_file`, `md_bytes`,
`docv_file` and `docv_bytes` as flat `metadata` keys returned by `files.query`,
and under `__file` in private documents and Smart Bucket copies. `src_file` is
the original record's permanent S3 path. Source size comes from DocLLM, or the
record's `size_bytes` when missing. Output paths must be S3 URIs and sizes
non-negative integers; missing or invalid output values are null.
The Markdown and JSON fields clear when conversion restarts or fails, and are
null for script-supplied content. Indexing-only retries preserve them.
These fields are gateway-owned and cannot be set through user metadata or content.

Conversion statistics are also stored as flat `metadata` keys and in `__file`:
`src_file_total_pages`, `md_file_total_pages`, `total_words` and `chunk_count`.
They come from DocLLM's `statistics` object and must be non-negative integers.
Missing page/word values are null; an omitted `chunk_count` uses the number of
saved chunks. Invalid values are null. Statistics clear with conversion outputs
on restart, ingestion failure, or script-supplied content, and are gateway-owned.

---

## files.update

Update a file's metadata or access.

```json
{
  "operation": "files.update",
  "payload": {
    "id": "7b21f51e730d40d0b0e373ef720e43d5",
    "public_read": true,
    "metadata": { "category": "approved" }
  }
}
```

**Parameters:**

| Field | Description |
|---|---|
| `id` | File ID (required) |
| `metadata` | Merged into existing metadata (shallow) |
| `public_read` | Change public access on the stored object |
| `filename` (alias `name`) | Rename the existing file record |
| `content_type`, `status`, `expires_at` | Record fields |
| `_xsync` | `true` refreshes size and status from storage |
| `_xlock` | `true` locks the file against deletion |

Returns the updated file information. There is no public unlock.

---

## files.reprocess

Retry document processing for an uploaded file you own. Admin and root callers
can retry any uploaded file. Agent keys need the `files.upload` permission and
always follow owner retry rules, even with an admin role. Web keys and anonymous
callers are refused.

```json
{
  "operation": "files.reprocess",
  "payload": {"id": "file_123"}
}
```

`id` (alias `_id`) is required. The file's upload `status` must be `completed`.
Work runs in the background; the immediate response is:

```json
{
  "data": {
    "id": "file_123",
    "doc_processing_status": "pending",
    "doc_processing_step": null,
    "doc_processing_attempts": 2
  }
}
```

If processing failed at indexing and ready, nonblank chunks remain, the response
has `doc_processing_status: "indexing"` and `doc_processing_step: "indexing"`;
only vector indexing runs. Other eligible retries submit a new DocLLM job and
ignore the previous job's callback. A retry increments attempts once.

Owners and agents may retry failed or unprocessed files, with four total starts
per file (the first run plus three retries). They cannot restart completed
processing. Admins and root may restart completed processing and have no attempt
limit. Everyone must wait while active processing was updated less than 30
minutes ago; stuck processing at least 30 minutes old may be restarted.

Query the file with `files.query` to read the seven gateway-managed flags at the
top level of its `metadata`: `doc_processing_status`, `doc_processing_step`,
`doc_processing_error`, `doc_processing_updated_at`, `doc_processing_attempts`,
`file_ingested`, and `file_indexed`. Status moves through `pending`, `ingestion`,
`indexing`, and `completed`, or `failed` with the last step and a short error.
Unqueued files start with null status, zero attempts, and false flags.
These flags also appear in the private document and Smart Bucket copies under
`__file`. User metadata and script content cannot set them.

**Errors:**

| Condition | Status | Error |
|---|---|---|
| Unknown file or no write access | 404 | `FILE_NOT_FOUND` |
| Upload incomplete | 409 | `FILE_NOT_COMPLETED` |
| Active processing updated less than 30 minutes ago | 409 | `FILE_PROCESSING_IN_PROGRESS` |
| Completed processing requested by an owner or agent | 403 | `FILE_ALREADY_PROCESSED` |
| Owner or agent already started processing four times | 429 | `FILE_REPROCESS_LIMIT` |
| Agent lacks `files.upload` | 403 | `AGENT_SCOPE_DENIED` |
| Web key or unauthenticated caller | 401/403 | Authentication required |
| Caller tries to write processing flags through `files.update` | 400 | `INVALID_FILE_METADATA_FIELD` |

---

## files.delete

Delete one file by `id`, or a batch by `ids`.

```json
{
  "operation": "files.delete",
  "payload": {
    "id": "file_123"
  }
}
```

**Response:**
```json
{
  "data": {
    "ids": ["file_123"],
    "change": "deleted"
  }
}
```

For batch deletion:

```json
{
  "operation": "files.delete",
  "payload": {
    "ids": ["file_123", "file_locked", "file_456"],
    "purge": false
  }
}
```

**Batch response:**

```json
{
  "data": {
    "ids": ["file_123", "file_456"],
    "failed": [{"id": "file_locked", "error": "FILE_LOCKED"}],
    "change": "deleted"
  }
}
```

**Parameters:**

| Field | Description |
|---|---|
| `id` (alias `_id`) | One file ID; required unless `ids` is provided |
| `ids` | Between 1 and 100 nonempty file IDs; selects batch behavior |
| `purge` | Permanently remove metadata records; default `false` |

**Behavior:**

- Provide `id`, `ids`, or both. Both are merged with `id` first; duplicates are removed in order. The merged selection cannot exceed 100 files.
- The stored object is always deleted. `purge` (default `false`) only controls whether the record is also removed permanently.
- A single `id` raises on failure. When `ids` is supplied, only successful deletions appear in the response's `ids`; each failure appears in `failed`, which is empty when all files succeed. Individual failures do not stop the batch.
- Unknown files and files the caller cannot write both report `FILE_NOT_FOUND`, so callers cannot probe other users' IDs. Admins can delete other users' files.
- Batches run up to 10 files concurrently. Unexpected failures are logged and reported as `FILE_NOT_DELETED`.
- Deletion also removes the private `__files` document and queues vector cleanup.
- Stored Markdown and `.docv.json` output objects are deleted too. Cleanup errors are logged without failing the file deletion.
- Deleting a profile photo clears the owner's `profile_photo`.
- Authentication and agent-key permission for `files.delete` remain required.

**Errors:**

| Condition | Status | Error |
|---|---|---|
| Neither `id` nor `ids` | 400 | `INVALID_PAYLOAD` with `MISSING_FILE_ID` in validation details |
| Empty IDs, empty batch, or more than 100 IDs | 400 | `INVALID_PAYLOAD` |
| Unknown file or no write access | 404 | `FILE_NOT_FOUND` |
| Locked file | 403 | `FILE_LOCKED` |
| Linked documents | 409 | `CANT_DELETE_FILE_HAS_LINKED_DOCUMENTS` |
| Record deletion fails | 500 | `FILE_NOT_DELETED` |

For batches, file errors appear as messages in `failed` rather than rejecting the request. Authentication and payload validation errors still reject the whole request.

::: danger
File bytes cannot be recovered after `files.delete`, even when `purge` is `false`.
:::

---

## Common Errors

| Message | Meaning |
|---|---|
| `FILE_FORBIDDEN` | The user does not own the file |
| `FILE_BUCKET_NOT_FOUND`, `FILE_BUCKET_DISABLED` | The bucket is missing or disabled |
| `FILE_TYPE_NOT_ALLOWED` | The bucket does not accept this file type |
| `FILE_UPLOAD_LIMIT_EXCEEDED` | Too many files in one upload batch |
| `UPLOADED_OBJECT_NOT_FOUND` | Nothing was uploaded to storage |
| `CONTENT_TYPE_MISMATCH`, `INVALID_UPLOADED_OBJECT` | The stored object does not match the upload |
| `FILE_LOCKED`, `CANT_DELETE_FILE_HAS_LINKED_DOCUMENTS` | The file cannot be deleted |
| `FILE_NOT_UPLOADED`, `FILE_NOT_CONVERTABLE` | The source is incomplete or cannot be converted |

See [Responses](/api.md#responses) for the error envelope.

---

## Next Steps

- [LLM](/llm.md) - Chat with uploaded files as attachments
- [Authentication](/auth.md) - Get the ID token for uploads
- [Agents](/agents.md) - Grant file permissions to AI agents
