Working with Objects
Examples below use BASE="http://localhost:39001/v1/storage/<project_id>" and the
bucket avatars. The SDK examples use db.storage.from('avatars').
Upload
curl -X PUT "$BASE/object/avatars/user-123/photo.jpg" \ -H "apikey: $SERVICE_KEY" \ -H "Content-Type: image/jpeg" \ --data-binary @photo.jpgconst { data, error } = await db.storage .from('avatars') .upload(`user-123/photo.jpg`, file, { contentType: 'image/jpeg', upsert: true, // overwrite if it exists })with open("photo.jpg", "rb") as f: db.storage.from_("avatars").upload("user-123/photo.jpg", f, {"content-type": "image/jpeg", "upsert": "true"})await db.storage.from('avatars').upload( 'user-123/photo.jpg', file, fileOptions: const FileOptions(contentType: 'image/jpeg', upsert: true),);The SDK sends a File/Blob as multipart/form-data; the storage facade
extracts the file part and stores the file bytes with the file’s own
content-type, so a later download is byte-identical (it never stores the
multipart envelope). A raw --data-binary / streamed upload is stored verbatim.
Default is upsert: false — existing paths are protected. An upload()
onto a path that already exists returns 409 The resource already exists
(error.statusCode === '409') and the previous object is left untouched. Pass
upsert: true (sent as the x-upsert: true header; the owned SDK sends a PUT
instead) to overwrite. This matches Supabase, so first-write-wins /
content-addressed / immutable-path patterns behave identically after migration.
Download
curl "$BASE/object/avatars/user-123/photo.jpg" \ -H "apikey: $ANON_KEY" -o photo.jpgconst { data: blob } = await db.storage.from('avatars').download('user-123/photo.jpg')data = db.storage.from_("avatars").download("user-123/photo.jpg")List
curl -X POST "$BASE/object/list/avatars" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"prefix":"user-123/"}'const { data: files } = await db.storage.from('avatars').list('user-123', { limit: 100, offset: 0, search: 'avatar', sortBy: { column: 'name', order: 'asc' },})The request body is fully honored (Supabase-parity):
prefix— scopes the listing to a folder. It is traversal-guarded (a../absolute prefix is rejected with400). An empty or absent body lists the bucket root.limit/offset— paginate. Listing pages past the S3 1000-key limit internally, so a folder with more than 1000 objects is not silently truncated.limitdefaults to100and is capped at1000. A single request is also bounded to the first 10 000 immediate entries as a safety cap.search— case-insensitive substring filter on the (relative) name.sortBy.{column, order}—column∈name | created_at | updated_at | last_accessed_at,order∈asc | desc(defaultname asc).
Results are folder-level (one directory level, like Supabase): immediate
files plus immediate sub-folders. Each name is returned relative to the
prefix (e.g. listing folder returns hello.txt, not folder/hello.txt). A
sub-folder is a { name, id: null, metadata: null } entry — consumers detect a
folder by id === null.
Current cut:
metadata.mimetypeisnullin list responses — the S3 listing does not carry content-type. Fetch the object (or, once available,.info()) for its content-type.
On a bucket with storage policies, list returns only the entries the bucket’s
select policy permits — see
Storage policies.
Delete
curl -X DELETE "$BASE/object/avatars/user-123/photo.jpg" -H "apikey: $SERVICE_KEY"await db.storage.from('avatars').remove(['user-123/photo.jpg'])db.storage.from_("avatars").remove(["user-123/photo.jpg"]).remove([...]) (what the SDKs call) issues a single DELETE /object/{bucket}
with a { "prefixes": [...] } body and deletes every listed path (each path is
authorized independently by your storage policies / owner-prefix rules; if any
path is not permitted the whole request is rejected and nothing is deleted). It
works for any number of paths, including one. To delete a single object
directly, DELETE /object/{bucket}/{path} also works.
curl -X DELETE "$BASE/object/avatars" -H "apikey: $SERVICE_KEY" \ -H "Content-Type: application/json" \ -d '{"prefixes":["user-123/photo.jpg","user-123/old.jpg"]}'Move and Copy
Relocate an object server-side (S3 CopyObject, plus a delete for move) —
no download/re-upload round-trip. .. and absolute paths are rejected.
Cross-bucket copy/move is honored: pass destinationBucket and the object is
relocated into that bucket (the destination bucket’s file_size_limit still
applies, so a copy can’t bypass a stricter cap). When destinationBucket is
omitted the relocate stays within the source bucket (the common case).
# Copy a.txt → b.txt within the bucketcurl -X POST "$BASE/object/copy" \ -H "apikey: $SERVICE_KEY" -H "Content-Type: application/json" \ -d '{"bucketId":"avatars","sourceKey":"a.jpg","destinationKey":"b.jpg"}'
# Cross-bucket copy: avatars/a.jpg → backups/a.jpgcurl -X POST "$BASE/object/copy" \ -H "apikey: $SERVICE_KEY" -H "Content-Type: application/json" \ -d '{"bucketId":"avatars","sourceKey":"a.jpg","destinationKey":"a.jpg","destinationBucket":"backups"}'
# Move (copy + remove source)curl -X POST "$BASE/object/move" \ -H "apikey: $SERVICE_KEY" -H "Content-Type: application/json" \ -d '{"bucketId":"avatars","sourceKey":"a.jpg","destinationKey":"b.jpg"}'await db.storage.from('avatars').copy('a.jpg', 'b.jpg')await db.storage.from('avatars').move('a.jpg', 'b.jpg')// Cross-bucket copy/moveawait db.storage.from('avatars').copy('a.jpg', 'a.jpg', { destinationBucket: 'backups' })db.storage.from_("avatars").copy("a.jpg", "b.jpg")db.storage.from_("avatars").move("a.jpg", "b.jpg")Signed URL
transformoptions (createSignedUrl(path, expiresIn, { transform })) are a known cut — image transforms are not yet plumbed through the signed path.
Streaming & Range requests
Object downloads are streamed straight from storage — the object is never
buffered into control-plane memory, so multi-GB files (including resumable
uploads) serve without pressuring RAM. Every download advertises
Accept-Ranges: bytes and forwards the object’s ETag, Last-Modified, and
Cache-Control.
Because the download path honors the HTTP Range header, media seeking and
resumable downloads work out of the box. A ranged request returns
206 Partial Content with a Content-Range header and exactly the requested
byte window:
curl -s -D- -o/dev/null \ -H "apikey: $SERVICE_KEY" -H "Range: bytes=0-9" \ "$BASE/object/files/video.mp4"# → HTTP/1.1 206 Partial Content# Accept-Ranges: bytes# Content-Range: bytes 0-9/<total>An unsatisfiable range returns 416 Range Not Satisfiable. Ranges are honored on
the authenticated, public, and signed-URL download routes alike.
Signed URLs
Mint a time-limited URL so a client can fetch (or upload) an object without your project key — the right way to serve private files to the browser.
curl -X POST "$BASE/object/sign/avatars/user-123/photo.jpg" \ -H "apikey: $SERVICE_KEY" -H "Content-Type: application/json" \ -d '{"expiresIn":3600}'# → { "signedURL": "/object/sign/avatars/user-123/photo.jpg?token=…" }const { data } = await db.storage .from('avatars') .createSignedUrl('user-123/photo.jpg', 3600) // seconds// data.signedUrl ← a full, fetchable URLres = db.storage.from_("avatars").create_signed_url("user-123/photo.jpg", 3600)final url = await db.storage.from('avatars').createSignedUrl('user-123/photo.jpg', 3600);The response signedURL is a control-plane-routed path the SDK composes into
a full URL (data.signedUrl). That URL is directly fetchable from a browser
with no project key — the ?token= is an expiring, HMAC-signed capability bound
to that exact bucket + path. It works for private buckets too (the token is
the access grant), and the control plane validates the token before streaming the
object. The token is never the internal storage endpoint.
For genuinely public assets, you can serve them through a stable download path; for
everything user-private, prefer signed URLs scoped to the object the authenticated
user owns. createSignedUrl 404s when the target object does not exist (it will
not mint a token for a key that would 404 on fetch).
Signed upload URLs (browser-direct uploads)
Mint a one-shot, op-bound upload URL so a browser can PUT a file directly
without ever holding a project key. createSignedUploadUrl(path) returns a
{ url, token }; the client then uploadToSignedUrl(path, token, file) with no
Authorization header — the ?token= is the credential. The token is bound to the
project, the exact key, and an insert operation (a download token can never be
replayed as an upload token), and expires after 2 hours.
# 1. Mint (authenticated)curl -X POST "$BASE/object/upload/sign/avatars/user-123/a.png" \ -H "apikey: $SERVICE_KEY"# → { "url": "/object/upload/sign/avatars/user-123/a.png?token=…", "token": "…" }
# 2. Upload to the signed URL (no auth header; token is the credential)curl -X PUT "$STORAGE_BASE/object/upload/sign/avatars/user-123/a.png?token=…" \ --data-binary @a.pngconst { data } = await db.storage.from('avatars').createSignedUploadUrl('user-123/a.png')await db.storage.from('avatars').uploadToSignedUrl('user-123/a.png', data.token, file)By default the mint refuses to overwrite an existing key (409 Duplicate); pass
x-upsert: true to allow it. The per-bucket file_size_limit/allowed_mime_types
and the project storage quota are enforced on the upload exactly as for a direct
write.
exists() and info()
Check for an object without downloading it, or read its metadata:
await db.storage.from('avatars').exists('user-123/a.png') // → boolean (HEAD, 200/404)await db.storage.from('avatars').info('user-123/a.png') // → { size, contentType, cacheControl, … }exists() maps to a HEAD /object/{bucket}/{path} (200 when present, 404 when
not); info() maps to GET /object/info/{bucket}/{path} and returns the object’s
size, content_type, cache_control, etag, and last_modified.
cacheControl and ?download
An upload’s cache directive is stored on the object and re-emitted on download so a
CDN/Traefik cache in front of the control plane honors it. storage-js sends it as a
cache-control: max-age=<n> request header (raw + multipart uploads) or a
cacheControl field/metadata pair (resumable uploads) — all are stored.
Append ?download (or ?download=filename) to any object/public/signed download to
force a Content-Disposition: attachment (with the given filename). This only ever
tightens the disposition — it never overrides the XSS inline-content-type
hardening.
curl "$BASE/object/public/avatars/report.pdf?download=report.pdf"# → Content-Disposition: attachment; filename="report.pdf"Organizing objects (a recommended convention)
Namespace by owner so access control is path-based and signed URLs are easy to scope:
avatars/<user_id>/<filename>documents/<org_id>/<doc_id>/<filename>exports/<user_id>/<timestamp>.zipYour server then only signs URLs for paths whose <user_id> / <org_id> matches
the authenticated principal.
Tips
- Always set a correct
Content-Typeon upload — it controls how the object is served (and AnvilBase’s safe-disposition rules). - Use
upsert: truewhen you want a stable path (e.g. a user’s single avatar). - Pair storage with Image Transformations to avoid storing multiple resized copies.
- Direct uploads are capped at 50 MB per request. For larger files use
Resumable Uploads (TUS 1.0.0), which the supabase-js
resumable
uploaddrives drop-in.
Next: Image Transformations.