Upload files

Send a file of up to 1 GB in parts, then follow it until it is searchable.

You can add a document in one of two ways, depending on the file.

The file isUseCalls
Text, up to 256 KiBA small text documentOne request. See Small text documents.
A PDF, an Office file, or any other file type ByteRover reads, up to 1 GBAn uploadStart, send the parts, complete. Covered on this page.

To load a whole folder, follow the recipe Upload a folder and search it.

Before you start

  • An API key with the Read and write scope that can reach the drive. See Authentication and API keys.
  • The drive ID. GET /v1/drives lists the drives your key can reach.

How an upload works

Start the upload

Call POST /v1/drives/{driveId}/uploads with the file's path, size and SHA-256. The response is an upload plan that gives the part size, the number of parts, and an address for each part.

Send each part

PUT each part's bytes to its address. Parts go directly to storage and bypass the API. Save the ETag header from each response.

Complete the upload

Call POST /v1/uploads/{uploadId}/complete with every part number and its ETag. The response contains the document and an operation.

Follow the operation

Poll GET /v1/operations/{operationId} until its status is done. At that point the document is searchable.

The full example

import hashlib
import os
import time

import requests

API = "https://byterover.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BYTEROVER_API_KEY']}"}


def call(method, url, **kwargs):
    response = requests.request(method, url, headers=HEADERS, **kwargs)
    response.raise_for_status()
    return response.json()


def upload(drive_id, local_path, path):
    # 1. Start: the path in the drive, the size and the SHA-256 of the file.
    digest = hashlib.sha256()
    with open(local_path, "rb") as file:
        for block in iter(lambda: file.read(1024 * 1024), b""):
            digest.update(block)
    plan = call("POST", f"{API}/drives/{drive_id}/uploads", json={
        "path": path,
        "size_bytes": os.path.getsize(local_path),
        "sha256": digest.hexdigest(),
    })

    # 2. Send each part to its address, and keep its ETag.
    sent = []
    parts, cursor = plan["parts"], plan["next_cursor"]
    with open(local_path, "rb") as file:
        while True:
            for part in parts:
                file.seek((part["part_number"] - 1) * plan["part_size"])
                put = requests.put(part["url"], data=file.read(plan["part_size"]))
                put.raise_for_status()
                sent.append({"part_number": part["part_number"], "etag": put.headers["ETag"]})
            if cursor is None:
                break
            page = call("GET", f"{API}/uploads/{plan['upload_id']}/parts", params={"cursor": cursor})
            parts, cursor = page["parts"], page["next_cursor"]

    # 3. Complete: the file becomes a document, and an operation starts.
    written = call("POST", f"{API}/uploads/{plan['upload_id']}/complete", json={"parts": sent})

    # 4. Follow the operation until the document is searchable.
    operation = written["operation"]
    while operation["status"] in ("queued", "running"):
        time.sleep((operation["retry_after_ms"] or 1000) / 1000)
        operation = call("GET", f"{API}/operations/{operation['id']}")
    if operation["status"] == "failed":
        raise RuntimeError(operation["error"]["message"])
    return written["document"]


document = upload("drv_4h7k2m9q8r1t5v6w3x0y", "customer-refund-policy.pdf", "/policies/customer-refund-policy.pdf")
print(document["id"])

Facts about an upload

FactValue
Largest file1 GB
Part size8 MiB. Every part is this size except the last, which can be smaller. Use part_size from the plan.
Addresses per response100. If next_cursor isn't null, fetch the next page with GET /v1/uploads/{uploadId}/parts?cursor=.
Address lifetime15 minutes (see expires_at). Use the same call to get fresh addresses.
Upload lifetime24 hours. After that the upload closes, the API returns UPLOAD_CLOSED, and you need to start a new one.
Calling complete againReturns the result of the first call, so retries are safe.
SHA-256Lowercase hex digest of the whole file. ByteRover checks it again before reading the file, and the document fails if it doesn't match.

The path decides the document

path is the document's location in the drive, including its name, for example /policies/customer-refund-policy.pdf. The name must end in a file type that ByteRover reads.

If no document exists at the path, ByteRover creates one. If a document already exists there, the upload adds a new revision to it, and searches read the new revision from then on. That's how you keep a file current. Any folders in the path are created for you.

Small text documents

Text up to 256 KiB goes in a single request, with no parts and no SHA-256.

curl -X PUT https://byterover.ai/api/v1/drives/drv_4h7k2m9q8r1t5v6w3x0y/documents/by-path \
  -H "Authorization: Bearer $BYTEROVER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "path": "/decisions/move-billing-queue.md",
    "text": "# Move billing off the old queue\n\n## Context\n\nRetries on the old queue could charge a customer twice.",
    "tags": ["payments"]
  }'

You get the same response as a completed upload: the document and an operation.

Tags

Add tags when you start an upload or send a small text document, and you can then limit searches to those files. Tags are a list of plain labels, such as ["legal", "2026"]. A document can have up to 32 tags, each 1 to 64 characters, with no = or control characters and no tag listed twice. See tags.

When you start an upload, you can also send content_type, the file's media type, such as application/pdf. It's optional and up to 255 characters.

If it fails

You getCauseWhat to do
400 INVALID_REQUESTThe path doesn't end in a file type ByteRover reads, the size is over 1 GB, or sha256 isn't a lowercase hex digest.Fix the request.
400 INVALID_REQUEST on completeThe list doesn't have every part, the parts aren't in order from 1, or an ETag doesn't match what storage received.Send every part once, in order, each with the ETag its PUT returned. Send a part again if its ETag doesn't match.
402 BALANCE_EXHAUSTEDThe file would take the team past its free storage, and the balance is empty.A team owner or admin adds money. See Pricing and billing.
403 FORBIDDENThe key has the Search scope.Use a key with the Read and write scope.
404 NOT_FOUNDThe drive doesn't exist, or the key can't reach it.Check the drive ID, and check which drives the key reaches with GET /v1/key.
409 UPLOAD_CLOSEDThe upload is already finished, or more than 24 hours old.Start a new upload.
503 STORAGE_UNAVAILABLEPart of ByteRover isn't available right now.Wait briefly, then retry.
503 QUEUE_UNAVAILABLE on completeThe file was saved, but its processing couldn't be queued, so its operation is failed. Completing again doesn't queue it.Wait briefly, then start a new upload to the same path.
The operation is failedByteRover couldn't read the file. error.message says why, and error.code names the reason, such as IMAGE_ONLY_PDF_UNSUPPORTED for a PDF that's only images.Fix the file and upload it again to the same path. A PDF that's only images can't be read yet.

For the full list of codes, see Errors and retries.

On this page