HTTP API
The plain HTTP API is for curl, shell scripts and any client that would rather send a Bearer token than sign requests. It is the same set of routes the S3 API uses; only the authentication and the response format differ. The routes live in app/routes.py.
All paths below are relative to the server root, or to path_prefix when one is configured.
Authentication
Send the bucket’s private key, or the master key, as a Bearer token:
Authorization: Bearer <key>
Reads on a public bucket need no header. Everything else does: writes, deletes, presigning, reads on a private bucket and listing buckets. Keys and access has the full table.
# write to a bucket
curl -X PUT http://localhost:8080/uploads/file.txt \
-H "Authorization: Bearer uploads-secret" \
--data-binary "hello"
# read from a public bucket, no key needed
curl http://localhost:8080/uploads/file.txt
# read from a private bucket
curl http://localhost:8080/permanent/doc.pdf \
-H "Authorization: Bearer perm-secret"
Routes
| Method | Path | Auth | What it does |
|---|---|---|---|
GET | /health | none | Returns {"status":"ok"} |
GET | / | master or bucket key | Lists buckets. The master key sees all of them, a bucket key sees only its own |
HEAD | /{bucket} | read | 200 if the bucket exists and the caller may read it, otherwise 404 |
PUT | /{bucket} | write | Does nothing. 200 if the bucket exists, 404 if not. It exists because boto3 may call create_bucket |
GET | /{bucket} | read | Lists objects |
GET | /{bucket}?location | none | S3 GetBucketLocation. Returns an empty LocationConstraint XML document |
PUT | /{bucket}/{key} | write | Uploads an object, replacing any object with the same key |
GET | /{bucket}/{key} | read | Downloads an object |
HEAD | /{bucket}/{key} | read | Object metadata without the body |
DELETE | /{bucket}/{key} | write | Deletes an object. 204 even if it did not exist |
POST | /presign/{bucket}/{key} | write | Creates a presigned URL. See Presigned URLs |
POST | /mcp/ | per tool | MCP Streamable HTTP endpoint. See MCP server |
“Read” means no key on a public bucket and the bucket key, master key or a presigned GET URL on a private one. “Write” means the bucket key, the master key or, for PUT /{bucket}/{key}, a presigned PUT URL.
Any other method on these paths gets 405 with {"detail":"Method Not Allowed"}.
Uploading
curl -X PUT http://localhost:8080/uploads/reports/2024/january.pdf \
-H "Authorization: Bearer uploads-secret" \
--data-binary @january.pdf
- The body is streamed to disk, not held in memory. When the bucket has a
max_file_sizeand the body grows past it, the upload stops with413 EntityTooLargeand nothing is stored. - Keys can contain
/. hybrids3 creates the parent directories on write and removes them again when they become empty after a delete. - A key that resolves outside the bucket directory, such as one with
../segments, gets400 InvalidKey. - The response is
200with an empty body and anETagheader holding the MD5 of the content in quotes. - Uploading to an existing key replaces the object and resets its TTL.
- The content type comes from the
Content-Typeheader, unless the header is missing,application/octet-streamorapplication/x-www-form-urlencoded(whatcurl -dand--data-binarysend). Then hybrids3 detects it.
Downloading and HEAD
GET /{bucket}/{key} returns the bytes. GET and HEAD both send:
| Header | Value |
|---|---|
Content-Type | The stored content type |
Content-Length | Size in bytes |
ETag | MD5 of the content, in double quotes |
Last-Modified | Time of the last write as an ISO 8601 UTC timestamp, such as 2026-11-10T07:18:56.200941+00:00. This is not the RFC 7231 date format HTTP clients usually expect |
A missing object gets 404 NoSuchKey.
Listing objects
curl "http://localhost:8080/uploads?prefix=images/&max-keys=50" \
-H "Authorization: Bearer uploads-secret"
| Parameter | Default | Meaning |
|---|---|---|
prefix | none | Only keys that start with this text. % and _ match themselves, not as wildcards |
max-keys | 1000 | Most objects to return, clamped to 1 to 1000. A non-integer gets 400 InvalidArgument |
Objects come back sorted by key. There is no pagination: with more than 1000 matching objects, narrow the prefix. The JSON response:
{
"bucket": "uploads",
"objects": [
{
"bucket": "uploads",
"key": "images/cat.jpg",
"content_type": "image/jpeg",
"size": 48231,
"etag": "49f68a5c8493ec2c0bf489821c21fc3b",
"uploaded_at": "2026-11-10T07:18:56.200941+00:00",
"expires_at": "2026-11-11T07:18:56.200941+00:00"
}
]
}
expires_at is null in buckets without a TTL.
Listing buckets
# the master key sees every bucket
curl http://localhost:8080/ \
-H "Authorization: Bearer your-master-key"
# a bucket key sees only its own bucket
curl http://localhost:8080/ \
-H "Authorization: Bearer uploads-secret"
The response is {"buckets":["uploads","permanent"]}. Without a valid key the answer is 403 AccessDenied.
Response format
A request that carries an Authorization: AWS4-HMAC-SHA256 ... header or any x-amz-* header is treated as an S3 request and gets XML. Everything else gets JSON.
JSON errors look like this:
{
"error": "NoSuchKey",
"message": "Object 'zz' not found",
"request_id": "393dba50439f"
}
S3 errors use the S3 <Error> document with Code, Message and RequestId.
Every response carries X-Request-Id, which matches the rid field in the logs, and X-Content-Type-Options: nosniff.
Error codes
| Status | Code | When |
|---|---|---|
400 | InvalidArgument | max-keys or expires is not an integer, or method is not GET or PUT |
400 | InvalidKey | The key resolves outside the bucket directory |
403 | AccessDenied | GET / without a valid key, or a presigned GET URL that is invalid or expired |
404 | NoSuchBucket | The bucket does not exist, the caller may not see it, or a presigned PUT URL is invalid or expired |
404 | NoSuchKey | The object does not exist |
413 | EntityTooLarge | The upload is larger than the bucket’s max_file_size |
503 | ServiceUnavailable | Too many requests are already waiting for this object (lock_max_waiters) |
503 | RequestTimeout | The request waited longer than lock_acquire_timeout or held the lock longer than lock_hold_timeout |
Storage, expiry and locking explains the 503 cases.
Reserved paths
FastAPI serves its generated API pages at /docs, /redoc and /openapi.json, without a key. They are matched before the bucket routes, so a bucket named docs, redoc or openapi.json cannot be listed with GET /{bucket}. Object routes under those names still work. Pick other bucket names.