Object storage

A drive for your files, reachable from code. Create buckets, put any file in them, browse them by folder, download them whole or in ranges, copy them and delete them. API keys can be limited to specific buckets, each with full or read-only access, so the key you ship inside an app only reaches the files it needs.

You can do all of this from the Object storage section of your dashboard too — it calls the same API.

Routes

Every path below is relative to https://khabot.com/api/v1.

Method & pathDoes
GET /storage/bucketsYour buckets, each with its stats
POST /storage/bucketsCreate a bucket — JSON {"name": "…"}
GET /storage/buckets/{bucket}One bucket's stats
DELETE /storage/buckets/{bucket}Delete an empty bucket
GET /storage/buckets/{bucket}/objectsList files and folders
PUT /storage/buckets/{bucket}/objects/{key}Upload a file (the raw bytes are the body)
GET /storage/buckets/{bucket}/objects/{key}Download a file — Range supported
HEAD /storage/buckets/{bucket}/objects/{key}A file's metadata, no body
DELETE /storage/buckets/{bucket}/objects/{key}Delete a file
POST /storage/buckets/{bucket}/objects/{key}/copyCopy a file — JSON {"to_key": "…", "to_bucket": "…"}
POST …/objects/{key}/multipartStart a multipart upload
PUT …/objects/{key}/multipart/{upload_id}/parts/{n}Upload part n
POST …/objects/{key}/multipart/{upload_id}/completeAssemble the parts into the file
DELETE …/objects/{key}/multipart/{upload_id}Abandon a multipart upload

Buckets

A bucket name is 3–63 characters of lowercase letters, digits and hyphens ([a-z0-9-]). Creating a name you already use returns 409 bucket_exists.

curl https://khabot.com/api/v1/storage/buckets \
  -H "Authorization: Bearer kb_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "assets"}'

Creating a bucket answers 201 with the new bucket. GET /storage/buckets returns {"buckets": [ … ]}, and GET /storage/buckets/{bucket} returns one bucket. Each bucket carries:

FieldMeaning
nameThe bucket name
bytes_storedTotal size of the files in it
object_countHow many files it holds
bytes_in, bytes_outBytes uploaded to it and downloaded from it
unbilled_transfer_bytesTransfer still under the 1 MB billing threshold (see Billing)
created_atWhen it was created
accrued_usdWhat the bucket has accrued this month, storage and transfer together

A bucket must be empty before it can be deleted — otherwise 409 bucket_not_empty. Delete its files first. A successful delete — of a bucket, a file or a pending multipart upload — answers 204 No Content.

Files

Keys and folders

A file's key is its full name inside the bucket, and slashes in it act as folders: reports/2026/q3.pdf lives in the reports/2026/ folder. Folders are not created on their own — one exists as soon as a file's key starts with it. Encode each path segment of the key in the URL (a space becomes %20), and keep the slashes.

multipart is reserved: it names the parts API under every key, so no segment of a key may be exactly multipart — multipart/a.txt and a/multipart/b.txt are refused with 400 bad_request. (multipart-notes.txt is fine.)

Upload

PUT the file's raw bytes as the request body. The Content-Type you send is stored with the file and returned when it is downloaded. Uploading to an existing key replaces that file. The response is {"key", "size", "etag"}.

curl -X PUT https://khabot.com/api/v1/storage/buckets/assets/objects/reports/q3.pdf \
  -H "Authorization: Bearer kb_YOUR_KEY" \
  -H "Content-Type: application/pdf" \
  --data-binary @q3.pdf

One PUT carries up to 1 GB; anything larger is refused with 413 object_too_large — use a multipart upload instead. For big files curl -T q3.pdf <url> streams from disk rather than loading the file into memory.

Download

GET the same path. The body is the file, sent with the Content-Type it was stored with. Send a Range header (bytes=0-1023) for part of the file — the answer is 206 Partial Content. HEAD returns the headers without the body.

Copy, move and delete

POST …/objects/{key}/copy with {"to_key": "…"} copies within the bucket; add "to_bucket" to copy into another bucket. There is no separate move: copy, then delete the original. DELETE removes a file; an unknown key is 404 object_not_found.

Listing

GET /storage/buckets/{bucket}/objects lists a bucket, one page at a time.

ParameterNotes
prefixOnly keys starting with this — e.g. reports/ to look inside that folder
delimiterPass / to list one folder level: deeper keys are rolled up into prefixes
limitPage size
cursorThe next_cursor from the previous page
{
  "objects": [
    { "key": "reports/q3.pdf", "size": 812345, "content_type": "application/pdf",
      "etag": "9b2cf535f27731c974343645a3985328", "last_modified": "2026-10-20T09:14:02Z" }
  ],
  "prefixes": ["reports/2025/"],
  "next_cursor": "cmVwb3J0cy9xMy5wZGY"
}

With delimiter=/, objects are the files directly in the folder and prefixes are its sub-folders. Keep passing next_cursor back as cursor until it comes back empty — then you have the last page.

Multipart uploads

For files over 1 GB — or whenever you want to upload in resumable pieces — send the file in parts:

  1. Start — POST …/objects/{key}/multipart returns {"upload_id"}. Send the file's Content-Type on this call.
  2. Upload the parts — PUT …/multipart/{upload_id}/parts/{n} with the raw bytes of part n, numbering from 1. Every part except the last must be at least 5 MB. Each answer is {"etag"} — keep it.
  3. Complete — POST …/multipart/{upload_id}/complete with {"parts": [{"n": 1, "etag": "…"}, …]}, in order. The file appears only now.

Changed your mind? DELETE …/multipart/{upload_id} abandons the upload and discards the parts. At most 20 multipart uploads can be pending per account at once — complete or abandon one before starting another — and an upload left idle for more than 24 hours is abandoned automatically.

BASE=https://khabot.com/api/v1/storage/buckets/assets/objects/backups/site.tar
AUTH="Authorization: Bearer kb_YOUR_KEY"

# 1. start
curl -X POST "$BASE/multipart" -H "$AUTH" -H "Content-Type: application/x-tar"
# → {"upload_id": "up_…"}

# 2. parts (split -b 64M site.tar part_)
curl -X PUT "$BASE/multipart/up_…/parts/1" -H "$AUTH" --data-binary @part_aa
# → {"etag": "…"}
curl -X PUT "$BASE/multipart/up_…/parts/2" -H "$AUTH" --data-binary @part_ab

# 3. complete
curl -X POST "$BASE/multipart/up_…/complete" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"parts": [{"n": 1, "etag": "…"}, {"n": 2, "etag": "…"}]}'

API keys and bucket scopes

A key reaches object storage when its service list includes it (or allows every service). On top of that, each key carries storage scopes — set them under Storage scopes when you create or edit a key in the dashboard:

SettingThe key can
All bucketsEverything, in every bucket — including creating and deleting buckets
Specific buckets, Read-onlyList the bucket, read its stats, download and HEAD its files
Specific buckets, FullAll of that, plus upload, multipart, copy into it and delete files

A key limited to specific buckets cannot create or delete buckets, and it only sees its own buckets in the bucket list. Copying needs read access to the source bucket and full access to the destination. Anything outside the key's scopes is refused with 403 storage_scope_denied. A signed-in dashboard session always has full access.

On the key object the setting is storage_scopes: null for all buckets, or a list of buckets with their access:

"storage_scopes": [
  { "bucket": "assets",  "access": "full" },
  { "bucket": "reports", "access": "read" }
]

Billing

Object storage has two prices, both on the pricing page:

PriceUnitWhat it measures
storage-storedGB-dayThe bytes you keep. 1 GB kept for one day is one unit; 10 GB kept for 30 days is 300.
storage-transferGBThe bytes you move, uploads and downloads alike.

Both are charged to your credit — not to a plan's quota — and appear in your transactions as Object storage.

  • Storage accrues continuously on what you actually store, so a file kept for an afternoon costs an afternoon — deleting files stops their accrual.
  • Small transfers carry over. A transfer under 1 MB isn't charged on its own: it is added to a running total, and the total is charged once it reaches 1 MB. Nothing is rounded up per request.

Each bucket's accrued_usd shows what it has accrued this month; the dashboard adds them up across your buckets.

When your balance goes below zero

Because storage keeps accruing whether or not you are using the API, your credit can go negative. When it does:

  • Uploads are paused while the balance is below zero — anything that adds data (uploads, multipart uploads, copies) and creating buckets is refused with 402 storage_balance_negative.
  • Downloads keep working for a grace period — currently 14 days — then pause too (also 402 storage_balance_negative).
  • Listing, bucket stats, HEAD and deleting — files, buckets and pending multipart uploads — keep working throughout, so you can bring the bill down without topping up first.
  • Nothing is deleted. Your buckets and files stay where they are.

The error's details say exactly where you stand: negative_since is when the balance went below zero, and read_access_until is when downloads stop (or stopped).

{
  "error": {
    "code": "storage_balance_negative",
    "message": "Your balance is negative — uploads and other writes are paused until it is topped up",
    "details": {
      "negative_since": "2026-10-20T08:00:00Z",
      "read_access_until": "2026-11-03T08:00:00Z"
    }
  }
}

Redeem a credit code to bring the balance back above zero, and everything resumes at once.

Errors

HTTPcodeWhen
400bad_requestAn invalid bucket name, a missing to_key, a part under 5 MB that isn't the last, or a parts list that doesn't match the uploaded parts — the message says which
402storage_balance_negativeYour balance is below zero — see above
403storage_scope_deniedThe bucket or action is outside this key's storage scopes
403service_not_allowed_for_keyThis key's service list doesn't include object storage
404object_not_foundNo file has that key
409bucket_existsYou already have a bucket with that name
409bucket_not_emptyDeleting a bucket that still holds files
413object_too_largeA single upload over 1 GB — use multipart
429rate_limitedToo many storage requests in a minute, or too many transfers running at once — wait and retry
503upstream_unavailableStorage is temporarily unreachable — safe to retry
503service_not_configuredObject storage isn't switched on

Authentication and rate-limit walls are the same as every other service — see Errors.