A 201 from confirm means the document row exists and processing was queued. It does not mean the file is indexed.
Upload files
Put one fictional text file into a stored collection and read its status back. Hands-on time is about 20 minutes. Queue time after confirm is extra and can be minutes.
Purpose
You can repeat the upload with urllib and explain each status. Level: someone who can call HTTP. Role: document:upload. Mode: stored. Reuse the workspace from Where to work. Provider keys are not required for the upload itself.
Sample
Create harbor-note.txt with exactly this line: Harbor Sample delivery window is 14 days. Format is txt. Do not use a real contract or a downloaded corpus. Delete the local file when you finish. The collection row remains until someone with collection management deletes it.
Steps
POST /api/collections/{collectionId}/documents/upload-urlwith the JSON below. ExpectdocumentId,storageKey,uploadUrl,expiresIn, andheaders.PUTthe file bytes touploadUrl. Send only theContent-Typefromheaders. Do not send the Bundata session or bearer token to that URL. Do not printuploadUrl.POST /api/collections/{collectionId}/documents/confirmwith the samedocumentId,storageKey, name, format, and byte size. A new row returns 201 andstatusprocessing.GET /api/collections/{collectionId}/documentsand find thatdocumentId. Stay onprocessinguntil it moves. There is no separate status URL.
Fields
| Field | Type | Rule |
|---|---|---|
filename | string | 1–500 characters. Used to build the storage key. |
format | enum | pdf, docx, txt, image, csv, xlsx. |
sizeBytes | integer | Optional on upload-url. Required on confirm. 0 through 500 MiB. Positive on upload-url when sent. |
contentType | string | Optional, max 200. The response headers are what the PUT must send. |
relativePath | string | Optional, max 2048. A folder-relative path for discovery. Not a metadata editor. |
documentId | uuid | Returned by upload-url. You do not invent it. |
storageKey | string | Returned by upload-url. Must still match the collection on confirm. |
Example
Non-browser clients use a bearer token from POST /auth/login. Read it from the environment. Never print it. Browser pages use the session cookie instead, and must not send both.
curl -sS -X POST "$BUNDATA_ORIGIN/api/collections/$COLLECTION_ID/documents/upload-url" \
-H "Authorization: Bearer $BUNDATA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename":"harbor-note.txt","format":"txt","sizeBytes":42,"contentType":"text/plain"}'
import json, os, urllib.request
origin = os.environ["BUNDATA_ORIGIN"].rstrip("/")
token = os.environ["BUNDATA_TOKEN"]
collection = os.environ["BUNDATA_COLLECTION_ID"]
body = json.dumps({"filename": "harbor-note.txt", "format": "txt", "sizeBytes": 42, "contentType": "text/plain"}).encode()
req = urllib.request.Request(origin + "/api/collections/" + collection + "/documents/upload-url", data=body, method="POST")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as res:
issued = json.load(res)
payload = b"Harbor Sample delivery window is 14 days.\n"
put = urllib.request.Request(issued["uploadUrl"], data=payload, method="PUT")
put.add_header("Content-Type", issued["headers"]["Content-Type"])
urllib.request.urlopen(put).close()
confirm = json.dumps({
"documentId": issued["documentId"], "storageKey": issued["storageKey"],
"name": "harbor-note.txt", "format": "txt", "sizeBytes": len(payload),
}).encode()
creq = urllib.request.Request(origin + "/api/collections/" + collection + "/documents/confirm", data=confirm, method="POST")
creq.add_header("Authorization", "Bearer " + token)
creq.add_header("Content-Type", "application/json")
with urllib.request.urlopen(creq) as res:
row = json.load(res)
assert res.status == 201
assert row["status"] == "processing"
The standard library is the only dependency. BUNDATA_TOKEN, BUNDATA_ORIGIN, and BUNDATA_COLLECTION_ID are placeholders you set in the environment.
Recovery
| Result | What to do |
|---|---|
201 and processing | Poll GET /api/collections/{id}/documents. Do not upload a second copy. |
| Same confirm again, identical fields | 200 and the existing row. Safe retry. |
Same documentId with a different name, size, or key | 409. Reconcile. Do not mint a new id to hide the conflict. |
S3_NOT_CONFIGURED or S3_PRESIGN_UNAVAILABLE | Upload is not available in that deployment. Stop. |
DOCUMENT_PROCESSING_QUEUE_UNAVAILABLE | The row may exist with error. Retry the same confirm. |
| 403 | The role lacks document:upload. |
upload-direct | A gated fallback. When the gate is off the route is not found. It is not the production contract. |
Errors use { "error": { "code", "message", "statusCode" } }. A signed URL expires. Request a new upload-url rather than retrying a dead PUT.
Limits
Images can be stored and are not discovery-eligible. There is no .meta.json sidecar import and no API that writes arbitrary file metadata. relativePath is a path, not a tag.
Check
Success: the document list shows harbor-note.txt moving from processing toward ready when text is available. Failure: a viewer token receives 403 and the list is unchanged.