Skip to content

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

Terminal window
curl -X PUT "$BASE/object/avatars/user-123/photo.jpg" \
-H "apikey: $SERVICE_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
const { 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

Terminal window
curl "$BASE/object/avatars/user-123/photo.jpg" \
-H "apikey: $ANON_KEY" -o photo.jpg
const { data: blob } = await db.storage.from('avatars').download('user-123/photo.jpg')
data = db.storage.from_("avatars").download("user-123/photo.jpg")

List

Terminal window
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 with 400). 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. limit defaults to 100 and is capped at 1000. 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}columnname | created_at | updated_at | last_accessed_at, orderasc | desc (default name 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.mimetype is null in 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

Terminal window
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.

Terminal window
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).

Terminal window
# Copy a.txt → b.txt within the bucket
curl -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.jpg
curl -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/move
await 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 transform options (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:

Terminal 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.

Terminal window
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 URL
res = 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.

Terminal window
# 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.png
const { 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.

Terminal window
curl "$BASE/object/public/avatars/report.pdf?download=report.pdf"
# → Content-Disposition: attachment; filename="report.pdf"

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>.zip

Your server then only signs URLs for paths whose <user_id> / <org_id> matches the authenticated principal.

Tips

  • Always set a correct Content-Type on upload — it controls how the object is served (and AnvilBase’s safe-disposition rules).
  • Use upsert: true when 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 upload drives drop-in.

Next: Image Transformations.