Skip to content

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 ready

The 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

OperationRequired Role
Upload (presign + confirm)member, admin, or owner
List, get, downloadAny authenticated member
Update metadatamember, admin, or owner
Delete own filemember, admin, or owner
Delete another member's fileadmin or owner only

Endpoints

MethodPathDescriptionAuthRate Limit
POST/files/presignInitiate upload — get presigned PUT URLJWT10/min
POST/files/:id/confirmConfirm upload completedJWT10/min
GET/filesList ready filesJWT60/min
GET/files/:idGet file metadataJWT60/min
GET/files/:id/downloadGet presigned download URLJWT60/min
PATCH/files/:idUpdate purpose / metadataJWT30/min
DELETE/files/:idDelete file from storage and recordsJWT30/min

Complete Upload Walkthrough

Here is the full three-step upload flow in one code block — the most common pattern you will use.

javascript
// 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"
python
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
FieldTypeRequiredConstraintsDescription
originalNamestringYesMax 255 charactersFilename as shown to the user
mimeTypestringYestext/csv, text/plain, application/pdf, image/png, image/jpegMIME type of the file
sizeBytesnumberYes1 – 10,485,760 (10 MB)Exact byte count of the file
purposestringNoMax 100 characters, default: generalIntended use (e.g. session-attachment)

Response

Response Example
json
{
  "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
FieldTypeDescription
fileIdstringFile record ID — use in subsequent requests
uploadUrlstringPresigned R2 PUT URL — upload bytes directly
expiresAtstringISO 8601 — URL expires at this time
Upload to R2

After receiving the presigned URL, PUT the file bytes directly:

bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: text/csv" \
  -H "Content-Length: 15420" \
  --data-binary @vocabulary-list.csv

Important: No Authorization header is sent to R2 — the presigned URL carries all credentials. The Content-Type and Content-Length headers must exactly match what was specified in /presign, or R2 will reject the upload with 403 Forbidden.

Code Examples

bash
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"
  }'
javascript
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.expiresAt
python
import 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
FieldTypeRequiredDescription
etagstringNoETag returned by R2 after upload

Use the fileId from POST /files/presign's response (data.fileId) as $FILE_ID below.

Response

Response Example
json
{
  "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:

json
{
  "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

bash
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 '{}'
javascript
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();
python
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
ParameterTypeDefaultDescription
qstring (≤200)Case-insensitive partial match on filename
cursorstringOpaque pagination cursor from a previous response
limitinteger (1–100)25Number of results per page
mediaTypestringimage, audio, video, document, or other
accessScopestringprivate, workspace, or account
processingStatestringraw, processed, or derived
purposestringExact-match on purpose label
tagsstringComma-separated tag list — returns files matching any tag
chainyIdUUIDFilter to files attached to a specific chainy
chainIdUUIDFilter to files attached to a specific chain
bitIdUUIDFilter to files attached to a specific bit
createdFromISO 8601Lower bound on createdAt
createdToISO 8601Upper bound on createdAt
updatedFromISO 8601Lower bound on updatedAt
updatedToISO 8601Upper bound on updatedAt

Response

Response Example
json
{
  "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
FieldTypeDescription
itemsarrayPage of file records
nextCursorstring|nullPass as cursor in the next request to get the next page; null means no more pages
idstringFile ID
originalNamestringFilename provided at upload time
mimeTypestringMIME type
mediaTypestringimage, audio, video, document, or other
fileSizeBytesstringFile size in bytes
purposestringIntended use label
accessScopestringprivate, workspace, or account
processingStatestringraw, processed, or derived
tagsstring[]Tags attached to this file
statusstringAlways ready in catalog listings
workspaceIdstringWorkspace this file belongs to
createdAtstringISO 8601 — upload initiated
confirmedAtstring|nullISO 8601 — upload confirmed
updatedAtstringISO 8601 — last metadata update

Note: fileSizeBytes is a string because the database stores it as a bigint. Cast with parseInt(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

bash
# 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"
javascript
// 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 },
  });
}
python
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

bash
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
json
{
  "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

bash
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
FieldTypeRequiredDescription
originalNamestringNoNew display name for the file
purposestringNoNew purpose label
metadataobjectNoArbitrary key-value metadata

Tip — AI analysis quality: When a file is later attached to an AI session, the resolver reads originalName and purpose from 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 meaningful purpose (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

bash
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

json
{ "data": null }

Code Example

bash
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]
StatusDescription
pendingRecord created, upload not yet confirmed. Not visible in GET /files.
readyUpload confirmed and verified. Available for use in sessions and downloads.
deletedPermanently removed from R2. Record is retained for audit purposes.

Error Reference

StatusCodeWhen it occurs
400BAD_REQUESTInvalid MIME type, file too large, wrong state for operation
401UNAUTHORIZEDMissing or expired JWT token
403FORBIDDENFile belongs to another account, or insufficient role for the operation
404NOT_FOUNDFile ID does not exist
429TOO_MANY_REQUESTSRate 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 pending until /confirm succeeds. They are not returned by GET /files and 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, not 404.
  • sizeBytes is a string — the database stores file size as bigint; the API serializes it as a string to avoid JavaScript precision loss for very large values. Cast with parseInt when 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

Built with purpose.