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.

The API validates file metadata and returns a presigned upload URL. Upload the file bytes to that URL, then confirm the upload through the API.

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://<upload-host>/<signed-path>",
    "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 (reserved for future integrity checks)

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 (step 2) 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
cursorstring—Opaque pagination cursor from a previous response
limitinteger (1–100)25Number of results per page
mediaTypestring—image, audio, video, document, or other
accessScopestring—private, workspace, or account
processingStatestring—raw, processed, or derived
purposestring—Exact-match on purpose label
tagsstring—Comma-separated tag list — returns files matching any tag
chainyIdUUID—Filter to files attached to a specific chainy
chainIdUUID—Filter to files attached to a specific chain
bitIdUUID—Filter to files attached to a specific bit
createdFromISO 8601—Lower bound on createdAt
createdToISO 8601—Upper bound on createdAt
updatedFromISO 8601—Lower bound on updatedAt
updatedToISO 8601—Upper 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 (serialized as string)
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 (file 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 (valid for 1 hour by default). 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 (file ID)

Response ​

Response Example ​
json
{
  "data": {
    "downloadUrl": "https://<download-host>/<signed-path>",
    "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 (max 255 chars)
purposestringNoNew purpose label (max 100 chars)
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 (file 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
403FORBIDDENInsufficient role for the operation, or the file's access scope excludes you within your own account
404NOT_FOUNDFile ID does not exist, or belongs to another account — both cases render identically
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 and workspace. Requests for another account's files return 404, identical to a nonexistent file ID — the API never discloses whether a foreign-account file exists.
  • 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 (for both upload and download URLs)

Built with purpose.