Enterprise Files
Upload and manage files within a workspace — reference documents, spreadsheets, images, and PDFs that your AI agents can read, reason over, and cite.
Files are stored in private Cloudflare R2 object storage. The API server never receives file bytes — instead, the server validates your metadata and issues a presigned PUT URL, and your client uploads directly to R2. This keeps the API lean, eliminates file size constraints from the HTTP gateway, and scales to concurrent uploads without bottlenecks.
How It Works
1. POST /files/presign → server validates metadata, returns { fileId, uploadUrl, expiresAt }
2. PUT <uploadUrl> → client uploads file bytes directly to R2 (no Authorization header)
3. POST /files/:id/confirm → server verifies the upload succeeded, marks file readyThe presigned uploadUrl has a 1-hour TTL by default. The Content-Type and Content-Length are cryptographically locked into the URL — R2 will reject uploads with mismatched MIME type or byte count.
Once confirmed, you can attach files to AI sessions and messages using their fileId.
Workspace Context Required
All file operations require a workspace context. Pass the x-workspace-id header. Files belong to a workspace and are visible to all workspace members.
Role Requirements
| Operation | Required Role |
|---|---|
| Upload (presign + confirm) | member, admin, or owner |
| List, get, download | Any authenticated member |
| Update metadata | member, admin, or owner |
| Delete own file | member, admin, or owner |
| Delete another member's file | admin or owner only |
Endpoints
| Method | Path | Description | Auth | Rate Limit |
|---|---|---|---|---|
| POST | /files/presign | Initiate upload — get presigned PUT URL | JWT | 10/min |
| POST | /files/:id/confirm | Confirm upload completed | JWT | 10/min |
| GET | /files | List ready files | JWT | 60/min |
| GET | /files/:id | Get file metadata | JWT | 60/min |
| GET | /files/:id/download | Get presigned download URL | JWT | 60/min |
| PATCH | /files/:id | Update purpose / metadata | JWT | 30/min |
| DELETE | /files/:id | Delete file from storage and records | JWT | 30/min |
Complete Upload Walkthrough
Here is the full three-step upload flow in one code block — the most common pattern you will use.
// Step 1 — Request a presigned URL
const presignRes = await fetch(`${BASE_URL}/files/presign`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"x-workspace-id": WORKSPACE_ID,
},
body: JSON.stringify({
originalName: file.name, // "vocabulary-list.csv"
mimeType: file.type, // "text/csv"
sizeBytes: file.size, // exact byte count
purpose: "session-attachment", // optional label
}),
});
const { data } = await presignRes.json();
// data.fileId — store this, you'll need it
// data.uploadUrl — presigned R2 URL
// data.expiresAt — ISO 8601, URL expires at this time
// Step 2 — Upload directly to R2 (no Authorization header)
await fetch(data.uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type, // must match what you sent to /presign
"Content-Length": String(file.size), // must match what you sent to /presign
},
body: file, // File or Blob
});
// Step 3 — Confirm the upload
const confirmRes = await fetch(`${BASE_URL}/files/${data.fileId}/confirm`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"x-workspace-id": WORKSPACE_ID,
},
body: JSON.stringify({}),
});
const { data: fileRecord } = await confirmRes.json();
console.log(fileRecord.status); // "ready"import requests
# Step 1 — Request a presigned URL
file_path = "vocabulary-list.csv"
with open(file_path, "rb") as f:
file_bytes = f.read()
presign = requests.post(
f"{BASE_URL}/files/presign",
headers={
"Authorization": f"Bearer {TOKEN}",
"x-workspace-id": WORKSPACE_ID,
},
json={
"originalName": "vocabulary-list.csv",
"mimeType": "text/csv",
"sizeBytes": len(file_bytes),
"purpose": "session-attachment",
},
)
data = presign.json()["data"]
# Step 2 — Upload directly to R2
requests.put(
data["uploadUrl"],
data=file_bytes,
headers={
"Content-Type": "text/csv",
"Content-Length": str(len(file_bytes)),
},
)
# Step 3 — Confirm the upload
confirm = requests.post(
f"{BASE_URL}/files/{data['fileId']}/confirm",
headers={
"Authorization": f"Bearer {TOKEN}",
"x-workspace-id": WORKSPACE_ID,
},
json={},
)
file_record = confirm.json()["data"]
print(file_record["status"]) # "ready"POST /files/presign
Validate file metadata and receive a presigned PUT URL for direct upload to R2.
Authentication: JWT Bearer token
Rate limit: 10/min
Request
Request Body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
originalName | string | Yes | Max 255 characters | Filename as shown to the user |
mimeType | string | Yes | text/csv, text/plain, application/pdf, image/png, image/jpeg | MIME type of the file |
sizeBytes | number | Yes | 1 – 10,485,760 (10 MB) | Exact byte count of the file |
purpose | string | No | Max 100 characters, default: general | Intended use (e.g. session-attachment) |
Response
Response Example
{
"data": {
"fileId": "018e1234-5678-7abc-9def-012345678901",
"uploadUrl": "https://chainabit-prod-files.account-id.r2.cloudflarestorage.com/...",
"expiresAt": "2026-03-17T11:00:00.000Z"
}
}Response Fields
| Field | Type | Description |
|---|---|---|
fileId | string | File record ID — use in subsequent requests |
uploadUrl | string | Presigned R2 PUT URL — upload bytes directly |
expiresAt | string | ISO 8601 — URL expires at this time |
Upload to R2
After receiving the presigned URL, PUT the file bytes directly:
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: text/csv" \
-H "Content-Length: 15420" \
--data-binary @vocabulary-list.csvImportant: No
Authorizationheader is sent to R2 — the presigned URL carries all credentials. TheContent-TypeandContent-Lengthheaders must exactly match what was specified in/presign, or R2 will reject the upload with403 Forbidden.
Code Examples
curl -X POST https://api.chainabit.com/api/v1/files/presign \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{
"originalName": "vocabulary-list.csv",
"mimeType": "text/csv",
"sizeBytes": 15420,
"purpose": "session-attachment"
}'const res = await fetch(`${BASE_URL}/files/presign`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"x-workspace-id": WORKSPACE_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({
originalName: "vocabulary-list.csv",
mimeType: "text/csv",
sizeBytes: 15420,
purpose: "session-attachment",
}),
});
const { data } = await res.json();
// data.fileId, data.uploadUrl, data.expiresAtimport requests
res = requests.post(
f"{BASE_URL}/files/presign",
headers={
"Authorization": f"Bearer {TOKEN}",
"x-workspace-id": WORKSPACE_ID,
},
json={
"originalName": "vocabulary-list.csv",
"mimeType": "text/csv",
"sizeBytes": 15420,
"purpose": "session-attachment",
},
)
data = res.json()["data"]POST /files/:id/confirm
Confirm that the upload to R2 is complete. The server issues a HEAD request to R2 to verify the object exists before marking the file as ready. A file will only appear in listings after confirmation.
Authentication: JWT Bearer token
Rate limit: 10/min
Request
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
etag | string | No | ETag returned by R2 after upload |
Use the
fileIdfrom POST /files/presign's response (data.fileId) as$FILE_IDbelow.
Response
Response Example
{
"data": {
"id": "018e1234-5678-7abc-9def-012345678901",
"originalName": "vocabulary-list.csv",
"mimeType": "text/csv",
"sizeBytes": "15420",
"purpose": "session-attachment",
"status": "ready",
"workspaceId": "wsp_abc123",
"createdAt": "2026-03-17T10:00:00.000Z",
"confirmedAt": "2026-03-17T10:01:30.000Z"
}
}Error: Upload Not Found
If you call /confirm before completing the PUT upload to R2, the server will return:
{
"statusCode": 400,
"message": "Upload not found in storage. Complete the presigned PUT upload before confirming."
}Always complete the R2 PUT before calling /confirm.
Code Examples
curl -X POST https://api.chainabit.com/api/v1/files/$FILE_ID/confirm \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{}'const res = await fetch(`${BASE_URL}/files/${fileId}/confirm`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"x-workspace-id": WORKSPACE_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const { data } = await res.json();import requests
res = requests.post(
f"{BASE_URL}/files/{file_id}/confirm",
headers={
"Authorization": f"Bearer {TOKEN}",
"x-workspace-id": WORKSPACE_ID,
},
json={},
)
data = res.json()["data"]GET /files
List files in the current workspace. Only ready and non-deleted files are returned. Results are ordered by upload date — newest first.
Files with private access scope are only visible to the uploader and workspace admins/owners.
Authentication: JWT Bearer token
Rate limit: 60/min
Request
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string (≤200) | — | Case-insensitive partial match on filename |
cursor | string | — | Opaque pagination cursor from a previous response |
limit | integer (1–100) | 25 | Number of results per page |
mediaType | string | — | image, audio, video, document, or other |
accessScope | string | — | private, workspace, or account |
processingState | string | — | raw, processed, or derived |
purpose | string | — | Exact-match on purpose label |
tags | string | — | Comma-separated tag list — returns files matching any tag |
chainyId | UUID | — | Filter to files attached to a specific chainy |
chainId | UUID | — | Filter to files attached to a specific chain |
bitId | UUID | — | Filter to files attached to a specific bit |
createdFrom | ISO 8601 | — | Lower bound on createdAt |
createdTo | ISO 8601 | — | Upper bound on createdAt |
updatedFrom | ISO 8601 | — | Lower bound on updatedAt |
updatedTo | ISO 8601 | — | Upper bound on updatedAt |
Response
Response Example
{
"data": {
"items": [
{
"id": "018e1234-5678-7abc-9def-012345678901",
"originalName": "vocabulary-list.csv",
"mimeType": "text/csv",
"mediaType": "document",
"fileSizeBytes": "15420",
"purpose": "session-attachment",
"accessScope": "workspace",
"processingState": "raw",
"tags": [],
"status": "ready",
"workspaceId": "wsp_abc123",
"createdAt": "2026-03-17T10:00:00.000Z",
"confirmedAt": "2026-03-17T10:01:30.000Z",
"updatedAt": "2026-03-17T10:01:30.000Z"
}
],
"nextCursor": null
}
}Response Fields
| Field | Type | Description |
|---|---|---|
items | array | Page of file records |
nextCursor | string|null | Pass as cursor in the next request to get the next page; null means no more pages |
id | string | File ID |
originalName | string | Filename provided at upload time |
mimeType | string | MIME type |
mediaType | string | image, audio, video, document, or other |
fileSizeBytes | string | File size in bytes |
purpose | string | Intended use label |
accessScope | string | private, workspace, or account |
processingState | string | raw, processed, or derived |
tags | string[] | Tags attached to this file |
status | string | Always ready in catalog listings |
workspaceId | string | Workspace this file belongs to |
createdAt | string | ISO 8601 — upload initiated |
confirmedAt | string|null | ISO 8601 — upload confirmed |
updatedAt | string | ISO 8601 — last metadata update |
Note:
fileSizeBytesis a string because the database stores it as abigint. Cast withparseInt(fileSizeBytes, 10)when needed.
Pagination
The catalog uses cursor-based pagination. Each page returns a nextCursor token. Pass it as ?cursor=<token> to fetch the next page. When nextCursor is null, you have reached the last page. Do not parse or store cursor internals — they are opaque and may change.
Code Examples
# Search by filename
curl "https://api.chainabit.com/api/v1/files?q=vocabulary&limit=10" \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"
# Filter by type + date range
curl "https://api.chainabit.com/api/v1/files?mediaType=document&createdFrom=2026-01-01T00:00:00Z" \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"
# Paginate with cursor
curl "https://api.chainabit.com/api/v1/files?limit=25&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"// Search by filename
const params = new URLSearchParams({ q: "vocabulary", limit: "10" });
const res = await fetch(`${BASE_URL}/files?${params}`, {
headers: {
Authorization: `Bearer ${TOKEN}`,
"x-workspace-id": WORKSPACE_ID,
},
});
const { data } = await res.json();
const { items, nextCursor } = data;
// Fetch next page
if (nextCursor) {
const nextParams = new URLSearchParams({ limit: "10", cursor: nextCursor });
const nextRes = await fetch(`${BASE_URL}/files?${nextParams}`, {
headers: { Authorization: `Bearer ${TOKEN}`, "x-workspace-id": WORKSPACE_ID },
});
}import requests
# Search by filename
res = requests.get(
f"{BASE_URL}/files",
headers={
"Authorization": f"Bearer {TOKEN}",
"x-workspace-id": WORKSPACE_ID,
},
params={"q": "vocabulary", "limit": 10},
)
body = res.json()
items = body["data"]["items"]
next_cursor = body["data"].get("nextCursor")GET /files/:id
Get metadata for a single file.
Authentication: JWT Bearer token
Rate limit: 60/min
Request
Path params: id
Response
Returns 403 Forbidden if the file belongs to another account — never 404 — to prevent existence probing across tenants.
Code Example
curl https://api.chainabit.com/api/v1/files/$FILE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"GET /files/:id/download
Get a presigned download URL. The URL points directly to R2 — no Authorization header is needed to download. Only files in ready status can be downloaded.
Authentication: JWT Bearer token
Rate limit: 60/min
Request
Path params: id
Response
Response Example
{
"data": {
"downloadUrl": "https://chainabit-prod-files.account-id.r2.cloudflarestorage.com/...",
"expiresAt": "2026-03-17T11:00:00.000Z"
}
}Tip: Download URLs expire after 1 hour. Do not cache them. Request a fresh URL from this endpoint each time you need to download or display a file.
Code Example
curl https://api.chainabit.com/api/v1/files/$FILE_ID/download \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"PATCH /files/:id
Update a file's display name, purpose, and/or metadata. Does not affect the stored object in R2.
Authentication: JWT Bearer token
Rate limit: 30/min
Request
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
originalName | string | No | New display name for the file |
purpose | string | No | New purpose label |
metadata | object | No | Arbitrary key-value metadata |
Tip — AI analysis quality: When a file is later attached to an AI session, the resolver reads
originalNameandpurposefrom the database and surfaces them to the model as low-priority context. The model still analyzes the file directly; the filename and purpose serve as optional hints about the user's intent. Setting a meaningfulpurpose(e.g."critique typography and color contrast") often produces more relevant analysis. The hint never overrides the user's actual question.
Response
Returns the updated file record — same shape as POST /files/:id/confirm's response, with the updated originalName, purpose, and/or metadata.
Code Example
curl -X PATCH https://api.chainabit.com/api/v1/files/$FILE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{ "purpose": "critique typography and color contrast" }'DELETE /files/:id
Delete a file. The object is permanently removed from R2 storage and the record is marked deleted. This operation is idempotent — calling it on an already-deleted file returns 200.
Members can delete their own files. Admins and owners can delete any file in the workspace.
Authentication: JWT Bearer token
Rate limit: 30/min
Request
Path params: id
Response
{ "data": null }Code Example
curl -X DELETE https://api.chainabit.com/api/v1/files/$FILE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "x-workspace-id: $WORKSPACE_ID"File Lifecycle
presign → [pending] → PUT to R2 → confirm → [ready] → delete → [deleted]| Status | Description |
|---|---|
pending | Record created, upload not yet confirmed. Not visible in GET /files. |
ready | Upload confirmed and verified. Available for use in sessions and downloads. |
deleted | Permanently removed from R2. Record is retained for audit purposes. |
Error Reference
| Status | Code | When it occurs |
|---|---|---|
| 400 | BAD_REQUEST | Invalid MIME type, file too large, wrong state for operation |
| 401 | UNAUTHORIZED | Missing or expired JWT token |
| 403 | FORBIDDEN | File belongs to another account, or insufficient role for the operation |
| 404 | NOT_FOUND | File ID does not exist |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded for the endpoint |
Notes
- No multipart/form-data — files are uploaded directly to R2 via a presigned PUT URL. The API server never buffers file bytes.
- Content-Type enforcement — the MIME type and file size are cryptographically locked into the presigned URL. R2 rejects uploads with mismatched values with
403 Forbidden. - Pending files — files remain
pendinguntil/confirmsucceeds. They are not returned byGET /filesand cannot be downloaded. - Private access — no file is ever publicly accessible. Downloads require a fresh presigned URL from the API.
- Tenant isolation — all files are scoped to the authenticated account. Requests for another account's files return
403, not404. sizeBytesis a string — the database stores file size asbigint; the API serializes it as a string to avoid JavaScript precision loss for very large values. Cast withparseIntwhen needed.- Allowed MIME types:
text/csv,text/plain,application/pdf,image/png,image/jpeg - Maximum file size: 10 MB (10,485,760 bytes)
- Presigned URL TTL: 1 hour