Uploading Files

Upload files to Blue via a one-step GraphQL mutation or a presigned-PUT REST flow for large files.


Blue stores files as File objects scoped to an organization (Company) and, usually, a workspace (Project). There are two ways to get bytes into storage:

  • GraphQL multipart upload (uploadFile / uploadFiles) — one request, up to 256 MB per file. Use this for almost everything.
  • REST presigned-PUT flow (GET /uploadsPUT to storage → PUT /uploads/{uid}/confirm) — three requests, up to 4.8 GB per file. Use this only for files larger than 256 MB, where streaming bytes straight to storage avoids holding the whole file in the API.
GraphQL uploadREST presigned PUT
RequestsOneThree
Per-file limit256 MB4.8 GB
BatchUp to 10 files (1 GB total)One file per flow
Transportmultipart/form-data to /graphqlPUT bytes to a signed storage URL
Best forAlmost everythingFiles over 256 MB

All examples authenticate with personal access token headers and target https://api.blue.cc. See Authentication for how to generate a token. Headers are case-insensitive; this page uses the canonical casing.


GraphQL upload

Use the uploadFile mutation to upload a single file and the uploadFiles mutation to upload a batch. Both use the GraphQL multipart request spec: the file (or files) variable is sent as null in the operations JSON and mapped to a multipart part.

Request

A single-file upload returns the created File:

mutation UploadFile($input: UploadFileInput!) {
  uploadFile(input: $input) {
    id
    uid
    name
    size
    type
    extension
    shared
    status
    createdAt
    project {
      id
      name
    }
    folder {
      id
      title
    }
  }
}

A freshly uploaded file is not placed in a folder, so folder is null unless you later move it. The status of a file created through uploadFile/uploadFiles is CONFIRMED immediately — the lifecycle PENDING state only applies to the REST flow below.

Parameters

UploadFileInput

ParameterTypeRequiredDescription
fileUpload!YesThe file to upload, sent as a multipart part.
companyIdString!YesOrganization ID or slug where the file is stored.
projectIdStringNoWorkspace ID or slug. Omit for an organization-level file.

UploadFilesInput

ParameterTypeRequiredDescription
files[Upload!]!YesThe files to upload. Maximum 10, 1 GB total per request.
companyIdString!YesOrganization ID or slug where the files are stored.
projectIdString!YesWorkspace ID or slug. Required for batch uploads.
projectId differs between the two mutations

uploadFile accepts a nullable projectId — omit it to attach the file to the organization rather than a workspace. uploadFiles requires projectId; every file in a batch lands in the same workspace.

Response

{
  "data": {
    "uploadFile": {
      "id": "clm4n8qwx000008l0g4oxdqn7",
      "uid": "cm8qujq3b01p22lrv9xbb2q62",
      "name": "report.pdf",
      "size": 204800,
      "type": "application/pdf",
      "extension": "pdf",
      "shared": false,
      "status": "CONFIRMED",
      "createdAt": "2026-05-29T12:00:00.000Z",
      "project": { "id": "project_123", "name": "Acme Onboarding" },
      "folder": null
    }
  }
}

Returns — File

FieldTypeDescription
idID!The file’s database ID.
uidString!The storage UID, used to build download URLs.
nameString!Original filename.
sizeFloat!File size in bytes.
typeString!MIME type.
extensionString!File extension, no leading dot.
sharedBoolean!Whether the file is publicly shareable. Defaults to false.
statusFileStatusCONFIRMED or PENDING. See File status.
createdAtDateTime!When the file was created.
projectProject!The workspace the file belongs to.
folderFolderThe folder the file is in, or null.

Full example

cURL

The multipart body has three parts: operations (the query and variables, with file set to null), map (which part fills which variable), and the file itself.

curl https://api.blue.cc/graphql \
  -H "X-Bloo-Token-ID: YOUR_TOKEN_ID" \
  -H "X-Bloo-Token-Secret: YOUR_TOKEN_SECRET" \
  -H "X-Bloo-Company-ID: company_123" \
  -H "X-Bloo-Project-ID: project_123" \
  -F 'operations={"query":"mutation UploadFile($input: UploadFileInput!) { uploadFile(input: $input) { id uid name size type extension } }","variables":{"input":{"file":null,"companyId":"company_123","projectId":"project_123"}}}' \
  -F 'map={"0":["variables.input.file"]}' \
  -F '0=@/path/to/report.pdf'

Python

import json
import os
import requests

BASE_URL = "https://api.blue.cc"
HEADERS = {
    "X-Bloo-Token-ID": "YOUR_TOKEN_ID",
    "X-Bloo-Token-Secret": "YOUR_TOKEN_SECRET",
    "X-Bloo-Company-ID": "company_123",
    "X-Bloo-Project-ID": "project_123",
}

def upload_file(filepath):
    filename = os.path.basename(filepath)
    query = """
    mutation UploadFile($input: UploadFileInput!) {
      uploadFile(input: $input) { id uid name size type extension status }
    }
    """
    variables = {"input": {"file": None, "companyId": "company_123", "projectId": "project_123"}}
    with open(filepath, "rb") as f:
        resp = requests.post(
            f"{BASE_URL}/graphql",
            headers=HEADERS,
            data={
                "operations": json.dumps({"query": query, "variables": variables}),
                "map": json.dumps({"0": ["variables.input.file"]}),
            },
            files={"0": (filename, f)},
        )
    resp.raise_for_status()
    result = resp.json()
    if "errors" in result:
        raise Exception(result["errors"])
    return result["data"]["uploadFile"]

print(upload_file("report.pdf"))

Batch upload

uploadFiles maps each file to its own multipart part:

mutation UploadFiles($input: UploadFilesInput!) {
  uploadFiles(input: $input) {
    id
    uid
    name
    size
    type
    extension
  }
}
curl https://api.blue.cc/graphql \
  -H "X-Bloo-Token-ID: YOUR_TOKEN_ID" \
  -H "X-Bloo-Token-Secret: YOUR_TOKEN_SECRET" \
  -H "X-Bloo-Company-ID: company_123" \
  -H "X-Bloo-Project-ID: project_123" \
  -F 'operations={"query":"mutation UploadFiles($input: UploadFilesInput!) { uploadFiles(input: $input) { id uid name } }","variables":{"input":{"files":[null,null],"companyId":"company_123","projectId":"project_123"}}}' \
  -F 'map={"0":["variables.input.files.0"],"1":["variables.input.files.1"]}' \
  -F '0=@/path/to/first.pdf' \
  -F '1=@/path/to/second.pdf'
{
  "data": {
    "uploadFiles": [
      {
        "id": "clm4n8qwx000008l0g4oxdqn7",
        "uid": "cm8qujq3b01p22lrv9xbb2q62",
        "name": "first.pdf",
        "size": 102400,
        "type": "application/pdf",
        "extension": "pdf"
      },
      {
        "id": "clm4n8qwx000108l0h5pyerao8",
        "uid": "cm8qujq3b01p32lrv9xbb2q73",
        "name": "second.pdf",
        "size": 158720,
        "type": "application/pdf",
        "extension": "pdf"
      }
    ]
  }
}

Errors

CodeWhen
BAD_USER_INPUTA file exceeds 256 MB, or a batch exceeds 10 files / 1 GB total.
PLAN_LIMIT_REACHEDThe upload would exceed the organization’s storage allowance or per-file tier cap.
FILES_DISABLED_FOR_ROLEThe caller’s role in the workspace has file uploads disabled.
PROJECT_NOT_FOUNDprojectId does not resolve, or the caller has no access to it.
COMPANY_NOT_FOUNDcompanyId is missing or does not resolve.

REST presigned-PUT upload

For files larger than 256 MB (up to 4.8 GB), use the REST flow. The API never holds the file: you PUT the bytes directly to a presigned storage URL.

The flow is three requests:

  1. GET /uploads — ask for a signed URL. The API creates a File row with status: PENDING and returns where to put the bytes.
  2. PUT the file bytes to the returned signed URL.
  3. PUT /uploads/{uid}/confirm — finalize. The API verifies the object landed and flips the row to status: CONFIRMED.

If you never confirm, a background verifier reconciles the row against storage shortly after the signing window closes, so an abandoned upload won’t leave a dangling PENDING row forever — but you should always confirm so the file is usable immediately.

Both headers are required

GET /uploads requires X-Bloo-Company-ID and X-Bloo-Project-ID. Omitting either returns 400 with "Company ID and Project ID are required".

Step 1 — Request a signed URL

GET https://api.blue.cc/uploads?filename=large.zip&size=734003200

Query parameters:

ParameterRequiredDescription
filenameYesThe filename, including extension. The MIME type and extension are derived from it.
sizeRecommendedFile size in bytes. Used to reject oversize uploads up front and to verify the object on confirm.

Response:

{
  "url": "https://s3.us-west-002.backblazeb2.com/bloo-uploads/companies/acme/projects/onboarding/uploads/2026/05/jdoe/cm8qujq3b01p22lrv9xbb2q62_large.zip?X-Amz-Expires=...&X-Amz-Signature=...",
  "method": "PUT",
  "headers": {
    "Content-Type": "application/zip"
  },
  "fields": {
    "Content-Type": "application/zip",
    "Key": "companies/acme/projects/onboarding/uploads/2026/05/jdoe/cm8qujq3b01p22lrv9xbb2q62_large.zip",
    "X-Key": "cm8qujq3b01p22lrv9xbb2q62"
  }
}

fields["X-Key"] is the file’s uid — keep it for Step 3 and for building download URLs.

Step 2 — PUT the bytes

PUT the raw file bytes to url, sending the Content-Type from headers. This goes straight to storage, not to the Blue API. Do not wrap it in multipart/form-data — send the body as-is.

Step 3 — Confirm

PUT https://api.blue.cc/uploads/cm8qujq3b01p22lrv9xbb2q62/confirm

Send the same X-Bloo-* auth headers. On success the API returns 200 with { "message": "File confirmed successfully", "status": "CONFIRMED" }. If the stored object’s size doesn’t match the declared size, confirm returns 400 and the file stays PENDING.

Full example

import os
import requests

BASE_URL = "https://api.blue.cc"
HEADERS = {
    "X-Bloo-Token-ID": "YOUR_TOKEN_ID",
    "X-Bloo-Token-Secret": "YOUR_TOKEN_SECRET",
    "X-Bloo-Company-ID": "company_123",
    "X-Bloo-Project-ID": "project_123",
}

def upload_large_file(filepath):
    filename = os.path.basename(filepath)
    size = os.path.getsize(filepath)

    # Step 1: request a signed URL
    resp = requests.get(
        f"{BASE_URL}/uploads",
        headers=HEADERS,
        params={"filename": filename, "size": size},
    )
    resp.raise_for_status()
    signed = resp.json()
    uid = signed["fields"]["X-Key"]

    # Step 2: PUT the raw bytes to storage
    with open(filepath, "rb") as f:
        put = requests.put(
            signed["url"],
            data=f,
            headers={"Content-Type": signed["headers"]["Content-Type"]},
        )
    put.raise_for_status()

    # Step 3: confirm
    confirm = requests.put(f"{BASE_URL}/uploads/{uid}/confirm", headers=HEADERS)
    confirm.raise_for_status()
    return uid

print(upload_large_file("large.zip"))

Errors

The REST endpoint returns HTTP status codes (it is not GraphQL):

StatusWhen
400filename missing, both company and project headers not supplied, the extension can’t be determined, or size exceeds 4.8 GB.
401No valid token.
403 (FILES_DISABLED_FOR_ROLE)The caller’s workspace role has file uploads disabled.
404Company or project not found; or, on confirm, the file row or stored object is missing.

Anonymous form uploads

The same endpoint accepts uploads from public forms without a token. Pass ?formId=<form-id> instead of auth headers; the upload is gated by the form being active and is capped at 500 MB. Confirm the same way with PUT /uploads/{uid}/confirm?formId=<form-id>. See Forms for building public forms.


File status

File.status is a FileStatus enum reflecting the upload lifecycle:

ValueMeaning
PENDINGThe File row exists but the bytes are not yet confirmed in storage. Set during Step 1 of the REST flow.
CONFIRMEDThe file is fully uploaded and usable. GraphQL uploads are CONFIRMED immediately.

Treat PENDING files as not-yet-available; serve and attach only CONFIRMED files.


Serving files

After upload, build a download URL from the file’s uid and name:

https://api.blue.cc/uploads/{uid}/{url_encoded_filename}

For uid: "cm8qujq3b01p22lrv9xbb2q62" and name: "report.pdf":

https://api.blue.cc/uploads/cm8qujq3b01p22lrv9xbb2q62/report.pdf

This redirects to a short-lived signed storage URL — no extra authentication needed once you have the link.

PatternDescription
/uploads/{uid}/{filename}Download the file.
/uploads/{uid}/{filename}?content-disposition=inlineDisplay in the browser instead of downloading.
/uploads/{uid}/50x50/{filename}50×50 thumbnail (images only).
/uploads/{uid}/500x500/{filename}500×500 preview (images only).
from urllib.parse import quote

file = result["data"]["uploadFile"]
download_url = f"https://api.blue.cc/uploads/{file['uid']}/{quote(file['name'])}"
Both segments are required

The URL needs both uid and filename — the uid alone returns 404. Always URL-encode the filename so spaces and special characters survive.


Attaching files to records and comments

There is no separate “attach file” mutation. To make an uploaded file appear as a clickable attachment inside a comment — exactly as it does in the Blue UI — embed its metadata as an HTML <div> in the comment body and set tiptap: true.

Comments are created with the createComment mutation; records are Todo objects, so a record comment uses category: TODO.

How it works

  1. Upload the file to get its uid, name, size, type, and extension.
  2. Embed each file as an attachment <div> in the comment HTML.
  3. Set tiptap: true in CreateCommentInputrequired for the API to parse the attachment markup.

Each attachment is one element:

<div
  class="attachment"
  file='{"uid":"FILE_UID","name":"FILENAME","size":SIZE_IN_BYTES,"type":"MIME_TYPE","extension":"EXT"}'
></div>

The file attribute is a JSON string built from the upload response:

FieldTypeExample
uidstring"cm8qujq3b01p22lrv9xbb2q62"
namestring"report.pdf"
sizenumber204800
typestring"application/pdf"
extensionstring"pdf"

CreateCommentInput

ParameterTypeRequiredDescription
htmlString!YesComment body as HTML, including any attachment <div>s.
textString!YesPlain-text fallback of the comment.
categoryCommentCategory!YesTODO, DISCUSSION, or STATUS_UPDATE.
categoryIdString!YesID of the record, discussion, or status update the comment belongs to.
tiptapBooleanNoSet true so attachment markup is parsed.
parentIdStringNoID of the comment this replies to.

Example

import json
import requests

BASE_URL = "https://api.blue.cc"
HEADERS = {
    "X-Bloo-Token-ID": "YOUR_TOKEN_ID",
    "X-Bloo-Token-Secret": "YOUR_TOKEN_SECRET",
    "X-Bloo-Company-ID": "company_123",
    "X-Bloo-Project-ID": "project_123",
    "Content-Type": "application/json",
}

def comment_with_attachments(record_id, message, files):
    attachments = "".join(
        f'<div class="attachment" file=\'{json.dumps({k: f[k] for k in ("uid","name","size","type","extension")})}\'></div>'
        for f in files
    )
    query = """
    mutation CreateComment($input: CreateCommentInput!) {
      createComment(input: $input) { id html text createdAt }
    }
    """
    variables = {
        "input": {
            "html": f"<p>{message}</p>{attachments}",
            "text": message,
            "category": "TODO",
            "categoryId": record_id,
            "tiptap": True,
        }
    }
    resp = requests.post(f"{BASE_URL}/graphql", headers=HEADERS, json={"query": query, "variables": variables})
    resp.raise_for_status()
    result = resp.json()
    if "errors" in result:
        raise Exception(result["errors"])
    return result["data"]["createComment"]

file = upload_file("report.pdf")          # from the GraphQL upload example
comment_with_attachments("todo_123", "Here is the report you requested.", [file])
{
  "data": {
    "createComment": {
      "id": "clm4n8qwx000208l0i6qzfbp9",
      "html": "<p>Here is the report you requested.</p><div class=\"attachment\" file='{\"uid\":\"cm8qujq3b01p22lrv9xbb2q62\",\"name\":\"report.pdf\",\"size\":204800,\"type\":\"application/pdf\",\"extension\":\"pdf\"}'></div>",
      "text": "Here is the report you requested.",
      "createdAt": "2026-05-29T12:01:00.000Z"
    }
  }
}

Common mistakes

MistakeResult
Omitting tiptap: trueThe attachment <div>s are ignored — files won’t appear in the comment.
Using a placeholder type (e.g. "file") instead of the real MIME typeThe file may not render correctly in the UI.
Building the URL with uid alone404 — the URL needs both uid and filename.
Not URL-encoding the filenameBroken links for filenames with spaces or special characters.

Attaching files to a file custom field

To populate a record’s file custom field, upload the file, then link it with the createTodoCustomFieldFile mutation.

mutation CreateTodoCustomFieldFile($input: CreateTodoCustomFieldFileInput!) {
  createTodoCustomFieldFile(input: $input)
}

CreateTodoCustomFieldFileInput

ParameterTypeRequiredDescription
todoIdString!YesID of the record (Todo) holding the field.
customFieldIdString!YesID of the file custom field.
fileUidString!Yesuid of the uploaded file.

The mutation returns Booleantrue on success. Do not request a sub-selection.

{ "data": { "createTodoCustomFieldFile": true } }

See File custom field for creating the field itself.


Registering a pre-existing object (createFile)

If you already have a file’s storage uid and metadata out of band — for example, you completed the presigned-PUT flow yourself and want a File row without calling /uploads/{uid}/confirm — use the createFile mutation. It is not part of the standard upload lifecycle; prefer uploadFile or the confirm step instead.

mutation CreateFile($input: CreateFileInput!) {
  createFile(input: $input) {
    id
    uid
    name
    status
  }
}

CreateFileInput

ParameterTypeRequiredDescription
uidString!YesStorage UID of the object.
nameString!YesFilename.
sizeFloat!YesSize in bytes.
typeString!YesMIME type.
extensionString!YesExtension, no leading dot.
companyIdString!YesOrganization ID or slug.
projectIdString!YesWorkspace ID or slug.
folderIdStringNoFolder to place the file in.
documentIdStringNoDocument to associate the file with.
sharedBooleanNoWhether the file is publicly shareable. Defaults to false.