Buckets & objects
List buckets and search their indexed objects.
List buckets
GET /v1/buckets returns every bucket in the key's workspace:
curl https://bucketpilot.io/api/v1/buckets \
-H "Authorization: Bearer $BUCKETPILOT_KEY"{
"buckets": [
{
"id": 12,
"name": "prod-data",
"awsBucketName": "acme-prod-data",
"region": "us-east-1",
"provider": "aws",
"objectCount": 48210,
"totalSizeBytes": 734003200000,
"indexStatus": "ready",
"indexedObjectCount": 48210,
"lastIndexedAt": "2026-07-15T04:12:09.000Z",
"createdAt": "2026-03-02T18:30:11.000Z"
}
]
}GET /v1/buckets/{id} returns one bucket in the same shape. provider is the hosting cloud — aws, r2 (Cloudflare R2), or gcs (Google Cloud Storage); R2 buckets report region auto.
Search objects
GET /v1/buckets/{id}/objects reads the search index (metadata only — key, size, storage class; never file contents), so the bucket must be indexed first. Results are paged.
| Parameter | Type | Description |
|---|---|---|
search | string | Text match on the key; * and ? work as wildcards (reports/*.csv). |
prefix | string | Limit to keys under a prefix. |
storageClass | string | e.g. STANDARD, GLACIER, DEEP_ARCHIVE. |
page, pageSize | integer | Pagination — default 50, max 200 per page. |
sortBy | string | key, size, or lastModified. |
sortDir | string | asc or desc. |
curl "https://bucketpilot.io/api/v1/buckets/12/objects?search=*.parquet&sortBy=size&sortDir=desc" \
-H "Authorization: Bearer $BUCKETPILOT_KEY"import requests
resp = requests.get(
"https://bucketpilot.io/api/v1/buckets/12/objects",
params={"search": "*.parquet", "sortBy": "size", "sortDir": "desc"},
headers={"Authorization": f"Bearer {KEY}"},
)
for obj in resp.json()["objects"]:
print(obj["key"], obj["size"]){
"objects": [
{
"key": "events/2026/06/day-30.parquet",
"size": 52428800,
"lastModified": "2026-07-01T00:14:55.000Z",
"etag": "\"9b2cf535f27731c974343645a3985328\"",
"storageClass": "STANDARD",
"contentType": "application/octet-stream"
}
],
"page": 1,
"pageSize": 50,
"totalCount": 1284
}Download objects
POST /v1/buckets/{id}/objects/download returns a short-lived presigned URL — your download comes straight from your own S3, BucketPilot never proxies the bytes. Send the object key in the body; expiresIn (seconds, default 3600) and versionId are optional.
⚠️ A presigned URL cannot outlive the credentials that signed it. The protocol allows up to seven days, and a bucket connected with access keys can use all of it — but a bucket connected through a role is signed with a temporary session, and the link stops working when that session ends, whatever you asked for. There is no error when it is created; it simply fails the first time someone uses it. So expiresIn is clamped server-side, and the response tells you the real ceiling in maxExpiresIn, what imposed it in limitedBy (session or sigv4), and why in limitReason. Read those rather than assuming the number you sent.
curl -X POST https://bucketpilot.io/api/v1/buckets/12/objects/download \
-H "Authorization: Bearer $BUCKETPILOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "reports/2026/june.csv", "expiresIn": 600 }'{
"downloadUrl": "https://acme-prod-data.s3.us-east-1.amazonaws.com/reports/2026/june.csv?X-Amz-...",
"key": "reports/2026/june.csv",
"expiresAt": "2026-07-16T10:24:03.000Z",
"maxExpiresIn": 3540,
"limitedBy": "session",
"limitReason": "This bucket is connected through a role, and a link can only outlive the role session that signed it…",
"anyoneWithTheLink": true
}The URL carries Content-Disposition: attachment, so fetching it saves the file rather than rendering it. And anyoneWithTheLink is always true, stated so nothing has to infer it: the signature is the authorisation — whoever holds the URL can download the object until it expires, with no sign-in and no way to revoke it short of deleting the object or rotating the credential.
Accept uploads from your own users
POST /v1/buckets/{id}/objects/upload-post returns a signed form — an action URL and a set of hidden fields — that a browser can post a file to directly. It is the thing a presigned PUT cannot do: a PUT URL is minted per file by something that already knows what the file is, while a POST policy is a small signed contract for a file nobody has chosen yet. Your users upload straight into your bucket; the bytes never touch your server, and your server never holds an S3 key.
curl -X POST https://bucketpilot.io/api/v1/buckets/12/objects/upload-post \
-H "Authorization: Bearer $BUCKETPILOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyPrefix": "incoming/", "contentTypePrefix": "image/", "maxBytes": 5242880, "expiresIn": 600 }'Post the returned fields and then the file, in that order, as multipart/form-data to url. Send each field once — S3 refuses a form carrying two key parameters. The response's postAs object repeats these rules alongside the policy so your code need not carry them.
A policy is a bearer capability — whoever holds it can upload until it expires, with no further identity check. So every one is scoped before it is signed:
- •Where.
keyfor one exact object, orkeyPrefixfor a folder. An emptykeyPrefixis refused: it would authorise a write anywhere in the bucket, which is the same as handing over a key. - •What the object gets called. Post
keyback exactly as it was given to you. With akeyPrefixthe value is a${filename}template, and S3 replaces it with the name of the file that was uploaded — so the object lands under your prefix, named after the file. Overwritingkeywith a name of your own is refused (AccessDenied … Policy Condition failed: ["eq", "$key", …]): the policy is bound to that value. If you need to choose the name, ask for a policy with an exactkeyinstead. - •How big.
maxBytesbecomes acontent-length-rangecondition and defaults to 25 MB. S3 makes this optional; BucketPilot does not, because without it the holder can put 5 TB in your bucket at your expense. - •What type.
contentTypefor one exact type, orcontentTypePrefixfor a family such asimage/. With a prefix, your form must include aContent-Typefield — an upload that omits it is refused, as is one outside the family. - •How long. Minutes, not days — capped at 12 hours and, on a role-connected bucket, at the signing session's own life.
⚠️ Objects uploaded this way are not indexed. The browser posts straight to your provider and BucketPilot never sees it, so nothing appears in search and no watched-folder notification fires until the next index run. Call POST /v1/buckets/{id}/objects/confirm with the key once your own upload succeeds if you want it visible immediately. The response says so in indexed: false.
Needs a read_write key: it hands out the ability to write.
Trigger indexing
Needs a read/write key. POST /v1/buckets/{id}/index starts (or resumes) building the search index — the same operation as Index this bucket in the app, with the same guardrails: it returns 403 INDEX_LIMIT_REACHED if your plan's indexed-object allowance is used up, 409 LARGE_BUCKET for very large scan-mode buckets (pass ?force=true to scan anyway, or switch the bucket to daily inventory indexing in the app), and 409 if an index job is already running. Add ?fresh=true to rebuild from scratch instead of resuming a checkpoint.
curl -X POST "https://bucketpilot.io/api/v1/buckets/12/index" \
-H "Authorization: Bearer $BUCKETPILOT_KEY"Returns 202 { "status": "indexing" }; poll GET /v1/buckets/{id} for indexStatus to become ready.
Object metadata, tags & versions
Read a single object's details live from your storage — no index needed:
- •
GET /v1/buckets/{id}/objects/metadata?key=…— size, content type, etag, last-modified, storage class, version id, and any user metadata. Add&versionId=…for a specific version. - •
GET /v1/buckets/{id}/objects/tags?key=…— the object's tag set. - •
GET /v1/buckets/{id}/objects/versions?prefix=…— object versions and delete markers (passmaxKeys≤ 1000, andkeyMarker/versionIdMarkerfrom the previous page to continue).
curl "https://bucketpilot.io/api/v1/buckets/12/objects/metadata?key=reports/2026/june.csv" \
-H "Authorization: Bearer $BUCKETPILOT_KEY"Upload objects (read/write key)
Uploading is two steps, so the bytes go straight to your storage and never through BucketPilot. Needs a read/write key.
- 1.
POST /v1/buckets/{id}/objects/uploadwith the target key (and optionalcontentType) returns a short-lived presigneduploadUrl. - 2.
PUTyour file to that URL. - 3.
POST /v1/buckets/{id}/objects/confirmwith the same key so BucketPilot indexes it — now it shows up in listings and cost.
# 1. get a presigned upload URL
UP=$(curl -s -X POST https://bucketpilot.io/api/v1/buckets/12/objects/upload \
-H "Authorization: Bearer $BUCKETPILOT_KEY" -H "Content-Type: application/json" \
-d '{ "key": "reports/2026/july.csv", "contentType": "text/csv" }')
# 2. PUT the bytes to the returned uploadUrl
curl -X PUT -T ./july.csv -H "Content-Type: text/csv" "$(echo "$UP" | jq -r .uploadUrl)"
# 3. confirm so it's indexed
curl -X POST https://bucketpilot.io/api/v1/buckets/12/objects/confirm \
-H "Authorization: Bearer $BUCKETPILOT_KEY" -H "Content-Type: application/json" \
-d '{ "key": "reports/2026/july.csv" }'Delete, copy, folders, tags & storage class (read/write key)
All need a read/write key. Each keeps the search index in sync automatically.
- •Delete —
POST /v1/buckets/{id}/objects/deletewith{ "keys": ["a.txt", "b/c.txt"] }(up to 1000). Returns{ deleted, errors }. - •Copy / move —
POST /v1/buckets/{id}/objects/copywith{ "sourceKey", "destinationKey", "move": false }(same bucket; setmove: trueto delete the source). - •Create folder —
POST /v1/buckets/{id}/folderswith{ "name": "invoices", "prefix": "2026/" }. - •Set tags —
PUT /v1/buckets/{id}/objects/tagswith{ "key", "tags": [{ "key": "team", "value": "finance" }] }(replaces the whole set). - •Change storage class —
PUT /v1/buckets/{id}/objects/storage-classwith{ "key", "storageClass": "GLACIER_IR" }(STANDARD, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, GLACIER, GLACIER_IR, DEEP_ARCHIVE, REDUCED_REDUNDANCY).
curl -X POST https://bucketpilot.io/api/v1/buckets/12/objects/delete \
-H "Authorization: Bearer $BUCKETPILOT_KEY" -H "Content-Type: application/json" \
-d '{ "keys": ["tmp/old.log", "tmp/older.log"] }'A read-only key on any of these returns 403 READ_ONLY_KEY.
Encryption and Object Lock (read/write key to change)
The same controls as the bucket's Security tab, with the same safeguards.
- •Read —
GET /v1/buckets/{id}/securityreturns default encryption, versioning, Object Lock (and its default retention), MFA Delete and public-access state. - •Default encryption —
PUT /v1/buckets/{id}/encryptionwith{ "type": "SSE-S3" }or{ "type": "SSE-KMS", "kmsKeyId": "alias/my-key", "bucketKeyEnabled": true }. A KMS key is tested with a real write before it is saved (S3 itself accepts a typo and then every upload fails); the answer carrieskeyCheck. Send"skipKeyCheck": trueto save without the test. - •Object Lock on the bucket —
PUT /v1/buckets/{id}/object-lockwith{ "enable": true, "acknowledgeIrreversible": true }(the bucket must have versioning on, and neither can ever be switched off), or{ "default": { "mode": "GOVERNANCE", "days": 30 } }to set the default retention ("default": nullremoves it). A Compliance default also needsacknowledgeIrreversible: true. - •One file —
GET /v1/buckets/{id}/objects/lock?key=…reads its retention and legal hold.PUTthe same path with{ "key", "versionId"?, "retention": { "mode": "GOVERNANCE", "retainUntil": "2027-01-01T00:00:00Z" }, "legalHold": "ON" }. A lock is only ever extended — a shorter date answers409 LOCK_CANNOT_BE_SHORTENED. A Compliance lock needsacknowledgeIrreversible: true.
These need the extra S3 permissions listed under Connecting your AWS account; without them the answer is 403 with the permission to add.
Copy or move files, folders or a whole bucket (read/write key)
POST /v1/buckets/{id}/objects/transfer starts a transfer job and answers straight away; poll GET /v1/buckets/{id}/transfers/{jobId} until it finishes.
curl -X POST https://bucketpilot.io/api/v1/buckets/12/objects/transfer \
-H "Authorization: Bearer $BUCKETPILOT_KEY" -H "Content-Type: application/json" \
-d '{ "wholeBucket": true, "copy": true, "destinationBucketId": 15, "toPrefix": "archive/" }'Body: keys and/or prefixes (what to move), or wholeBucket: true for everything; destinationBucketId (another bucket; omit for the same one); toPrefix; copy (true keeps the source, false moves it). A whole-bucket transfer is copy or move only. The same plan gates as the app apply.