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

WhereVisibility
Studio Storage tabPrivate, or public with Public bucket checked
JavaScript and Node.js SDK storage.from('name').createBucket() with a service role keyPrivate by default, or public with { public: true }
Flutter SDK storage.from('name').createBucket() with a service role keyPrivate
REST POST /api/project/:slug/storage/:bucket/create with a service role keyPrivate by default, or public with {"public": true}
MCP orbitnest_create_bucketPrivate 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:

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

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

ActionAnon keyUser access tokenService role key
List and download, public bucketAll filesAll filesAll files
List and download, private bucketNo (403)Files the user uploadedAll files
UploadNo (403)Yes, owned by the user. The bucket must already exist (404 otherwise)Yes. A missing bucket is created as private
DeleteNo (403)Files the user uploadedAny file
Create or delete bucketsNoNoYes

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:

FieldNotes
fileThe file, up to 50 MB. Its part's content type becomes the stored MIME type
pathDestination 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

TypeScript
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:

TypeScript
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

Dart
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

Terminal
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

TypeScript
const { data: blob, error } = await orbit.storage.from('avatars').download('user-1/avatar.png');
Dart
final Uint8List bytes = await orbitnest.storage.from('avatars').download('user-1/avatar.png');
Terminal
curl "https://api.orbitnest.io/api/project/my-app/storage/avatars/download/user-1/avatar.png" \
  -H "Authorization: Bearer $ORBITNEST_ANON_KEY" -o avatar.png

The 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

TypeScript
const { data, error } = await orbit.storage.from('avatars').list('user-1/', { limit: 50 });
Dart
final files = await orbitnest.storage.from('avatars').list(prefix: 'user-1/');
Terminal
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

TypeScript
const { data, error } = await orbit.storage.from('avatars').remove(['user-1/old.png', 'user-1/older.png']);
Dart
final result = await orbitnest.storage.from('avatars').remove(['user-1/old.png']);
Terminal
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:

text
https://api.orbitnest.io/api/public/:slug/storage/:bucket/:path

The SDKs build this URL locally without a network call:

TypeScript
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.png
Dart
final 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:

text
https://api.orbitnest.io/api/public/my-app/storage/avatars/user-1/avatar.png?width=128&height=128&format=webp&quality=80&fit=cover
ParameterValues
width, heightPositive integers in pixels. Values above 4000 are reduced to 4000. Images are never enlarged
formatwebp, jpeg (or jpg), png or avif
quality1–100. With no format, the image is re-encoded in its original format
fitcover (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:

TypeScript
const { data } = orbit.storage.from('avatars').getPublicUrl('user-1/avatar.png', {
  transform: { width: 128, height: 128, format: 'webp', quality: 80, fit: 'cover' },
});
Dart
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 pathPurpose
GET /:bucket?prefix=&limit=List files
POST /:bucket/uploadUpload a file (multipart)
GET /:bucket/download/*pathDownload a file
GET /buckets/:bucket/objects/*pathDownload a file (alias)
GET /:bucket/public-url/*pathReturn { publicUrl } for a path
DELETE /:bucketDelete files listed in { "paths": [...] }
POST /:bucket/createCreate a bucket, or set its visibility with { "public": true } or { "public": false } (service role only)
DELETE /:bucket/deleteDelete 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

LimitValue
File size50 MB per file
List page size100 by default, 1000 maximum
Image transform output4000 px per side
Image transform input40 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:

JSON
{
  "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.