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 & path | Does |
|---|---|
GET /storage/buckets | Your buckets, each with its stats |
POST /storage/buckets | Create a bucket — JSON {"name": "…"} |
GET /storage/buckets/{bucket} | One bucket's stats |
DELETE /storage/buckets/{bucket} | Delete an empty bucket |
GET /storage/buckets/{bucket}/objects | List 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}/copy | Copy a file — JSON {"to_key": "…", "to_bucket": "…"} |
POST …/objects/{key}/multipart | Start a multipart upload |
PUT …/objects/{key}/multipart/{upload_id}/parts/{n} | Upload part n |
POST …/objects/{key}/multipart/{upload_id}/complete | Assemble 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:
| Field | Meaning |
|---|---|
name | The bucket name |
bytes_stored | Total size of the files in it |
object_count | How many files it holds |
bytes_in, bytes_out | Bytes uploaded to it and downloaded from it |
unbilled_transfer_bytes | Transfer still under the 1 MB billing threshold (see Billing) |
created_at | When it was created |
accrued_usd | What 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.
| Parameter | Notes |
|---|---|
prefix | Only keys starting with this — e.g. reports/ to look inside that folder |
delimiter | Pass / to list one folder level: deeper keys are rolled up into prefixes |
limit | Page size |
cursor | The 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:
- Start —
POST …/objects/{key}/multipartreturns{"upload_id"}. Send the file'sContent-Typeon this call. - Upload the parts —
PUT …/multipart/{upload_id}/parts/{n}with the raw bytes of partn, numbering from1. Every part except the last must be at least 5 MB. Each answer is{"etag"}— keep it. - Complete —
POST …/multipart/{upload_id}/completewith{"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:
| Setting | The key can |
|---|---|
| All buckets | Everything, in every bucket — including creating and deleting buckets |
| Specific buckets, Read-only | List the bucket, read its stats, download and HEAD its files |
| Specific buckets, Full | All 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:
| Price | Unit | What it measures |
|---|---|---|
storage-stored | GB-day | The bytes you keep. 1 GB kept for one day is one unit; 10 GB kept for 30 days is 300. |
storage-transfer | GB | The 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,
HEADand 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
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | An 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 |
| 402 | storage_balance_negative | Your balance is below zero — see above |
| 403 | storage_scope_denied | The bucket or action is outside this key's storage scopes |
| 403 | service_not_allowed_for_key | This key's service list doesn't include object storage |
| 404 | object_not_found | No file has that key |
| 409 | bucket_exists | You already have a bucket with that name |
| 409 | bucket_not_empty | Deleting a bucket that still holds files |
| 413 | object_too_large | A single upload over 1 GB — use multipart |
| 429 | rate_limited | Too many storage requests in a minute, or too many transfers running at once — wait and retry |
| 503 | upstream_unavailable | Storage is temporarily unreachable — safe to retry |
| 503 | service_not_configured | Object storage isn't switched on |
Authentication and rate-limit walls are the same as every other service — see Errors.