Platform
Storage
Store files in public or private buckets, upload, download, list and delete them with the SDKs or REST, and serve resized images from public URLs.
Every project has file storage organized into buckets. Apps upload and read files through the storage API with a project API key or a user's access token, and files in public buckets can also be served from plain URLs, with optional on-the-fly image resizing.
Buckets
A bucket is a named folder of files inside a project. File paths within a bucket can contain / to form folders, for example user-1/avatar.png.
- Public buckets serve their files from an unauthenticated public URL. Use them for avatars, product images and other assets anyone may see.
- Private buckets (the default) have no public URL. Their files are only reachable through the authenticated storage API: signed-in users can read only the files they uploaded, the service role key can read everything, and the anon key gets
403. See Access rules.
There are no signed URLs and no per-bucket access policies.
Create a bucket
| Where | Visibility |
|---|---|
| Studio Storage tab | Private, or public with Public bucket checked |
JavaScript and Node.js SDK storage.from('name').createBucket() with a service role key | Private by default, or public with { public: true } |
Flutter SDK storage.from('name').createBucket() with a service role key | Private |
REST POST /api/project/:slug/storage/:bucket/create with a service role key | Private by default, or public with {"public": true} |
MCP orbitnest_create_bucket | Private by default, or public with public: true |
Uploading a file with the service role key, from Studio or with MCP to a bucket that does not exist creates it as a private bucket. Uploading with a user access token to a bucket that does not exist returns 404.
To create a public bucket, or to switch an existing bucket between public and private, call the create endpoint with the service role key:
curl -X POST "https://api.orbitnest.io/api/project/my-app/storage/avatars/create" \
-H "Authorization: Bearer $ORBITNEST_SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{"public": true}'The create endpoint changes an existing bucket's visibility only when you send public. If you leave public out, an existing bucket keeps its visibility and a new bucket is private, so calling createBucket() with no options is a safe way to make sure a bucket exists.
await client.storage.from('avatars').createBucket({ public: true });Studio's bucket form accepts lowercase letters, digits and hyphens.
Delete a bucket
Deleting a bucket permanently deletes every file in it. Delete buckets from the Studio Storage tab, with orbitnest_delete_bucket, or with DELETE /api/project/:slug/storage/:bucket/delete and the service role key.
Access rules
The storage API at /api/project/:slug/storage accepts three kinds of credentials. It reads Authorization: Bearer <token> first, then an apikey header, then x-api-key.
| Action | Anon key | User access token | Service role key |
|---|---|---|---|
| List and download, public bucket | All files | All files | All files |
| List and download, private bucket | No (403) | Files the user uploaded | All files |
| Upload | No (403) | Yes, owned by the user. The bucket must already exist (404 otherwise) | Yes. A missing bucket is created as private |
| Delete | No (403) | Files the user uploaded | Any file |
| Create or delete buckets | No | No | Yes |
A file uploaded with a user access token is owned by that user. Files uploaded with the service role key or from Studio have no owner, so in a private bucket signed-in users cannot list, download or delete them; serve shared files from a public bucket instead.
Bucket names starting with _orbitnest are reserved by the platform, for example _orbitnest_gdpr_exports for analytics exports. This API returns 403 for them with every kind of key. Admins see them in Studio.
User access tokens must be access tokens from a live session. Refresh tokens are rejected, and a token from a signed-out session, or for a deleted or banned user, gets 401.
Storage is available on every plan, including the Studio Storage tab and the MCP storage tools. Uploads count toward your workspace's storage allowance (see Limits).
Upload files
Uploads are multipart/form-data with these fields:
| Field | Notes |
|---|---|
file | The file, up to 50 MB. Its part's content type becomes the stored MIME type |
path | Destination path in the bucket, such as user-1/avatar.png. Paths that escape the bucket with .., and paths with line breaks or null bytes, are rejected (the SDKs reject any .. segment) |
upsert | "true" to replace an existing file. Without it, uploading to an existing path fails with 400 |
A user cannot overwrite a file that another user uploaded. In the response, url is the file's public URL in a public bucket and null in a private bucket.
JavaScript and Node.js
const bucket = orbit.storage.from('avatars');
const { data, error } = await bucket.upload('user-1/avatar.png', file, { upsert: true });
// data: { id, bucket, path, filename, originalName, mimeType, size, url, etag, createdAt }upload(path, file, { upsert, contentType }) accepts a Blob, File, ArrayBuffer or Uint8Array (or a Buffer in Node.js). While a user is signed in, the SDKs send the user's access token, so the upload is owned by that user. Without a session they send the client's API key, so a browser client using the anon key has its uploads rejected.
Upload from a server with the Node.js SDK and the service role key:
import { readFile } from 'node:fs/promises';
import { createClient } from '@orbitneststudio/node';
const client = createClient({ apiKey: process.env.ORBITNEST_SERVICE_KEY! });
const bytes = await readFile('./avatar.png');
const { data, error } = await client.storage
.from('avatars')
.upload('user-1/avatar.png', bytes, { upsert: true, contentType: 'image/png' });The stored MIME type is contentType if you pass it, otherwise the Blob's own type, otherwise a guess from the path's extension (common image, audio, video, text and archive types), otherwise application/octet-stream. Files stored as application/octet-stream download as attachments and are skipped by image transforms.
Flutter
final bucket = orbitnest.storage.from('avatars');
final file = await bucket.upload(
path: 'user-1/avatar.png',
bytes: imageBytes,
contentType: 'image/png',
upsert: true,
);While a user is signed in, the Flutter SDK sends the user's access token, so the upload is owned by that user. Without a session it sends the anon key and the upload is rejected.
REST
curl -X POST "https://api.orbitnest.io/api/project/my-app/storage/avatars/upload" \
-H "Authorization: Bearer $ORBITNEST_SERVICE_KEY" \
-F "file=@avatar.png;type=image/png" \
-F "path=user-1/avatar.png" \
-F "upsert=true"The response is { "data": { ...file }, "error": null }.
Download files
const { data: blob, error } = await orbit.storage.from('avatars').download('user-1/avatar.png');final Uint8List bytes = await orbitnest.storage.from('avatars').download('user-1/avatar.png');curl "https://api.orbitnest.io/api/project/my-app/storage/avatars/download/user-1/avatar.png" \
-H "Authorization: Bearer $ORBITNEST_ANON_KEY" -o avatar.pngThe alias GET /api/project/:slug/storage/buckets/:bucket/objects/*path serves the same download. Who can download what is listed in Access rules. In a private bucket, a signed-in user who asks for a file they did not upload gets 404.
Downloads are sent with X-Content-Type-Options: nosniff. Images, audio, video and PDFs are served inline. Every other type, including SVG, is sent with Content-Disposition: attachment, so an uploaded HTML or SVG file can't run script on the API's origin.
List files
const { data, error } = await orbit.storage.from('avatars').list('user-1/', { limit: 50 });final files = await orbitnest.storage.from('avatars').list(prefix: 'user-1/');curl "https://api.orbitnest.io/api/project/my-app/storage/avatars?prefix=user-1/&limit=50" \
-H "Authorization: Bearer $ORBITNEST_SERVICE_KEY"prefix matches the start of the file path. limit defaults to 100 and is capped at 1000. Each item has name, path, size, mimeType and createdAt.
There is no API-key endpoint that lists a project's buckets. Use the Studio Storage tab or orbitnest_list_buckets.
Delete files
const { data, error } = await orbit.storage.from('avatars').remove(['user-1/old.png', 'user-1/older.png']);final result = await orbitnest.storage.from('avatars').remove(['user-1/old.png']);curl -X DELETE "https://api.orbitnest.io/api/project/my-app/storage/avatars" \
-H "Authorization: Bearer $ORBITNEST_SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{"paths": ["user-1/old.png"]}'The result lists the deleted paths and an errors entry for each path that was not found or that the caller may not delete.
Public URLs
Files in public buckets are served without authentication at:
https://api.orbitnest.io/api/public/:slug/storage/:bucket/:pathThe SDKs build this URL locally without a network call:
const { data } = orbit.storage.from('avatars').getPublicUrl('user-1/avatar.png');
console.log(data.publicUrl);
// https://api.orbitnest.io/api/public/my-app/storage/avatars/user-1/avatar.pngfinal url = orbitnest.storage.from('avatars').getPublicUrl('user-1/avatar.png');Requests for a file in a private bucket return 403, and missing files return 404. Responses are sent with Cache-Control: public, max-age=3600, an ETag, and Access-Control-Allow-Origin: *, so they can be used directly in img tags and from any origin. The nosniff and Content-Disposition rules for downloads apply here too. SVG files are sent as attachments but still display in img tags.
Public URLs, with or without image transforms, allow 300 requests per minute per IP address. Above that they return 429. Put a CDN in front of the URLs for heavier traffic.
Image transforms
Add query parameters to a public URL to get a resized or re-encoded rendition of an image:
https://api.orbitnest.io/api/public/my-app/storage/avatars/user-1/avatar.png?width=128&height=128&format=webp&quality=80&fit=cover| Parameter | Values |
|---|---|
width, height | Positive integers in pixels. Values above 4000 are reduced to 4000. Images are never enlarged |
format | webp, jpeg (or jpg), png or avif |
quality | 1–100. With no format, the image is re-encoded in its original format |
fit | cover (default), contain, fill, inside or outside. Applies when resizing |
- Invalid values are ignored rather than rejected.
- SVG, GIF and non-image files are returned unchanged.
- Source images larger than 40 megapixels can't be transformed, and the request fails with
500. - The server encodes only a few images at a time and queues a few more. When the queue is full, the request returns
503; retry after a short wait. - Transforms only apply to public URLs. Renditions up to 4 MB are cached in memory on the server, so repeat requests are fast.
The SDKs add the parameters for you:
const { data } = orbit.storage.from('avatars').getPublicUrl('user-1/avatar.png', {
transform: { width: 128, height: 128, format: 'webp', quality: 80, fit: 'cover' },
});final url = orbitnest.storage.from('avatars').getPublicUrl(
'user-1/avatar.png',
transform: const StorageTransform(width: 128, height: 128, format: 'webp', quality: 80),
);Manage storage in Studio and MCP
Open a project and select Storage to browse buckets, create private or public buckets, upload and download files, delete files and delete buckets.
The MCP server has orbitnest_list_buckets, orbitnest_create_bucket (with an optional public flag), orbitnest_delete_bucket, orbitnest_list_files, orbitnest_upload_file (base64 for binary content), orbitnest_download_file, orbitnest_delete_files, orbitnest_get_public_url and orbitnest_get_bucket_size. They use the same admin endpoints as Studio. Both are available on every plan.
REST reference
All routes are under https://api.orbitnest.io/api/project/:slug/storage and need one of the credentials in Access rules.
| Method and path | Purpose |
|---|---|
GET /:bucket?prefix=&limit= | List files |
POST /:bucket/upload | Upload a file (multipart) |
GET /:bucket/download/*path | Download a file |
GET /buckets/:bucket/objects/*path | Download a file (alias) |
GET /:bucket/public-url/*path | Return { publicUrl } for a path |
DELETE /:bucket | Delete files listed in { "paths": [...] } |
POST /:bucket/create | Create a bucket, or set its visibility with { "public": true } or { "public": false } (service role only) |
DELETE /:bucket/delete | Delete a bucket and all its files (service role only) |
Public files are served from GET https://api.orbitnest.io/api/public/:slug/storage/:bucket/*path without credentials.
Limits
| Limit | Value |
|---|---|
| File size | 50 MB per file |
| List page size | 100 by default, 1000 maximum |
| Image transform output | 4000 px per side |
| Image transform input | 40 megapixels |
Storage allowances by plan are listed in Plans & billing. The allowance covers every project in the workspace. An upload that would take the workspace over it fails with 402:
{
"statusCode": 402,
"error": "Payment Required",
"code": "QUOTA_EXCEEDED",
"quota": "storage",
"plan": "free",
"limit": 1073741824,
"usage": 1073000000,
"message": "Storage quota exceeded on the free plan. Delete files or upgrade for more storage."
}Replacing a file with upsert counts only the difference in size. Downloads and deletes keep working while the workspace is over its allowance.