REST API

Upload files, create collections, and manage shares over HTTP. All responses are JSON; no API key is required for anonymous uploads.

Introduction

The storage.to API powers our CLI, desktop app, web uploader, and any third-party client you want to build.

The upload flow is three steps:

  1. Init - tell us you want to upload a file. We return one or more presigned URLs pointing at Cloudflare R2.
  2. Upload to R2 - PUT your bytes directly to the presigned URL(s). Bytes don't pass through our servers.
  3. Confirm - tell us the upload finished. We create a File record and hand you a shareable URL.

Base URL

https://storage.to/api

All endpoints below are relative to this base. Example: POST /upload/init means POST https://storage.to/api/upload/init.

Authentication

Anonymous uploads work without authentication, but with tight limits. Keyless callers are capped at 50 files per rolling 24 hours per device or IP, plus the bandwidth quotas below, and files expire after 3 days.

Authenticating with a free account unlocks:

  • No daily file cap
  • No upload bandwidth quota
  • Uploads attached to your account (visible at /dashboard)
  • Premium features (permanent files, larger storage)
  • Ownership-based mutations (delete, set password, change expiry) without needing the visitor-token match

Limits at a glance

LimitNo token (anonymous)Free account tokenPaid account token
Files per day50 / 24 hoursUnlimitedUnlimited
Upload bandwidth100 GB / 24 hours
(500 GB per IP)
UnlimitedUnlimited
Max file size25 GB25 GB100 GB
File expiry3 days by default, up to 7 via expiry_days3 days by default, up to 7 via expiry_daysNever (files are permanent)
Storage poolNone (files expire)None (files expire)100 GB - 1 TB permanent, by plan

There are no monthly quotas - all limits are rolling 24-hour windows or per-minute rate limits. The free plan has no fixed storage pool: every file expires on its own, so nothing accumulates against a quota.

To authenticate, generate a personal API token from your account and send it as a bearer token on every request:

Authorization: Bearer <token>

Generate API token

You need to be signed in. The full token is shown only once at creation, so copy it somewhere safe. You can revoke a token at any time from the same page.

Visitor token

Anonymous clients need a way to prove ownership of their own uploads without an account. We use a visitor token - a random string the client generates once and reuses. Send it with every request:

X-Visitor-Token: <random-string>

On the web, the token is stored in the visitor_token cookie automatically. The CLI stores it at ~/.config/storageto/token (see CLI docs).

For mutation endpoints (delete, set password, change expiry), ownership is confirmed if either the visitor token matches or the request comes from the same IP that created the file. Both can be lost (cleared cookies, network changes). The owner token is the preferred proof going forward.

Owner token

Every resource-creating endpoint (/upload/init multipart, /upload/confirm, /file/reserve, /collection) returns an owner_token in its response. The token is a signed proof of ownership tied to that specific resource, independent of your IP or visitor token.

Store the token alongside the resource ID and send it on any mutation as:

Authorization: Owner <token>

Or, if you are already using Authorization: Bearer for an authenticated session, send it as:

X-Owner-Token: <token>

The server accepts the owner token as a valid ownership proof alongside the legacy visitor token + IP fallback - clients that hold the token keep working after switching networks or clearing cookies, and clients that do not still work exactly as before.

Tokens live as long as the resource does, are safe to persist, and do not expire independently. A lost token means losing control of that resource (file/collection/upload) - treat them like local passwords.

Errors

Errors follow a consistent shape:

{
  "success": false,
  "error": "Human-readable message"
}

Common HTTP status codes:

CodeMeaning
200OK.
201Created.
400Bad request (e.g. collection size limit exceeded).
401Password required or incorrect.
403Not authorized (not the owner of the resource).
404Resource not found or expired.
422Validation failed, or plan/quota restriction.
429Rate limit or upload quota hit.
500Server error. Check status.

Rate limits

All rate limits are per-IP. A 429 response includes standard Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining headers.

ScopeLimit
Upload init / confirm / abort60 / minute
Multipart completion500 / minute
Multipart part URLs120 / minute
Batch init / confirm500 / minute
Status polls (file & collection)120 / minute
Settings (password, expiry, max-downloads)30 / minute
Password verification10 / minute
Collection create30 / minute
Manage (ready, delete)60 / minute
Thumbnail upload120 / minute
ShareX upload20 / day
App analytics / errors120 and 60 / minute

Upload quota: anonymous clients have two ceilings running in parallel - 100 GB / 24 h per visitor token and 500 GB / 24 h per IP (the IP ceiling catches tokenless traffic and shared networks). When either is exceeded you'll get a 429 with details. This is an upload quota only - downloads are unlimited and unthrottled (served directly from R2 signed URLs).

Upload

The three-step upload flow for any file, including files over 5 GB (automatically multipart). If you only need a quick screenshot-style upload, see ShareX instead.

POST/upload/init60/min

Initiate an upload. For files >50 MB the response is a multipart upload (type: "multipart" field); otherwise a single presigned PUT.

Request body

FieldTypeDescription
filenamestring · requiredOriginal filename. Max 255 chars.
content_typestring · requiredMIME type.
sizeinteger · requiredFile size in bytes. Min 1.
Request
curl -X POST https://storage.to/api/upload/init \ -H "Content-Type: application/json" \ -H "X-Visitor-Token: abc123" \ -d '{ "filename": "report.pdf", "content_type": "application/pdf", "size": 2202009 }'
Response · single upload
{ "success": true, "type": "single", "upload_url": "https://r2.cloudflarestorage.com/...signed...", "headers": { "Host": ["..."] }, "r2_key": "uuid-abc123" }
Response · multipart
{ "success": true, "type": "multipart", "upload_id": "01HXYZ...", "r2_key": "uuid-abc123", "part_size": 33554432, "total_parts": 4, "initial_urls": { "1": "https://...", "2": "https://..." }, "owner_token": "owner_v1_..." }
POST/upload/partsOwner only120/min

Request additional part URLs for an in-progress multipart upload. Used when /init returned fewer URLs than you have parts (or they expired).

Request body

FieldTypeDescription
upload_idstring · requiredThe upload_id from /init.
part_numbersarray<int> · requiredPart numbers to get URLs for.
Request
curl -X POST https://storage.to/api/upload/parts \ -H "Content-Type: application/json" \ -d '{ "upload_id": "01HXYZ...", "part_numbers": [3, 4] }'
Response
{ "success": true, "part_urls": [ { "partNumber": 3, "url": "https://..." }, { "partNumber": 4, "url": "https://..." } ] }
POST/upload/complete-multipartOwner only500/min

Finalize a multipart upload on R2 once all parts are uploaded.

Request body

FieldTypeDescription
upload_idstring · requiredThe upload_id from /init.
partsarray · requiredEach entry: { partNumber, etag } from the R2 response.
Request
curl -X POST https://storage.to/api/upload/complete-multipart \ -H "Content-Type: application/json" \ -d '{ "upload_id": "01HXYZ...", "parts": [ { "partNumber": 1, "etag": "\"abc...\"" }, { "partNumber": 2, "etag": "\"def...\"" } ] }'
Response
{ "success": true }
POST/upload/abortOwner only60/min

Cancel a multipart upload and clean up any partial data on R2.

Request body

FieldTypeDescription
upload_idstring · requiredThe upload to abort.
Request
curl -X POST https://storage.to/api/upload/abort \ -H "Content-Type: application/json" \ -d '{ "upload_id": "01HXYZ..." }'
POST/upload/confirm60/min

Confirm the upload is complete. This is when we create the File record and return the shareable URL.

Request body

FieldTypeDescription
filenamestring · requiredOriginal filename.
sizeinteger · requiredFile size in bytes.
content_typestring · requiredMIME type.
r2_keystring · requiredThe r2_key from /init.
collection_idstring · optionalAttach to a collection.
crc32integer · optionalCRC32 checksum for integrity verification.
file_idstring(9) · optionalFulfil a previously reserved file ID.
Request
curl -X POST https://storage.to/api/upload/confirm \ -H "Content-Type: application/json" \ -H "X-Visitor-Token: abc123" \ -d '{ "filename": "report.pdf", "size": 2202009, "content_type": "application/pdf", "r2_key": "uuid-abc123" }'
Response
{ "success": true, "file": { "id": "FQxyz1234", "url": "https://storage.to/FQxyz1234", "filename": "report.pdf", "size": 2202009, "human_size": "2.1 MB", "expires_at": "2026-04-15T12:00:00Z" }, "owner_token": "owner_v1_..." }
POST/file/reserve60/min

Reserve a file ID and shareable URL before the bytes are ready. Useful when you need to hand out a link first and fulfil the upload afterwards. Ownership is bound to your visitor token + IP. Finish the upload later with /upload/init + /upload/confirm, passing file_id to confirm.

Request body

FieldTypeDescription
filenamestring · optionalPlaceholder filename. Defaults to "Pending".
content_typestring · optionalPlaceholder MIME type.
Request
curl -X POST https://storage.to/api/file/reserve \ -H "X-Visitor-Token: abc123"
Response
{ "success": true, "file": { "id": "FQxyz1234", "url": "https://storage.to/FQxyz1234", "expires_at": "2026-04-12T18:00:00Z" }, "owner_token": "owner_v1_..." }
POST/upload/init-batch500/min

Batch equivalent of /upload/init, optimised for the web uploader. Initiates up to 250 files in one round-trip.

Used internally by the web uploader. Most clients should prefer single-file /upload/init.

POST/upload/confirm-batch500/min

Batch equivalent of /upload/confirm. Confirms many files in one round-trip.

Collections

A collection groups multiple files under a single share URL (/c/{id}). Up to 10,000 files and 25 GB total.

POST/collection30/min

Create a new collection. Attach files afterwards by passing collection_id on /upload/confirm.

Request body

FieldTypeDescription
expected_file_countinteger · optionalHint for auto-marking the collection ready once all expected files have confirmed.
Request
curl -X POST https://storage.to/api/collection \ -H "Content-Type: application/json" \ -H "X-Visitor-Token: abc123" \ -d '{ "expected_file_count": 3 }'
Response
{ "success": true, "collection": { "id": "ABC123xyz", "url": "https://storage.to/c/ABC123xyz", "expires_at": "2026-04-15T12:00:00Z" }, "owner_token": "owner_v1_..." }
GET/collection/{id}/status120/min

Poll the state of a collection. Also auto-marks the collection ready if all expected files have confirmed.

Request
curl https://storage.to/api/collection/ABC123xyz/status
Response
{ "success": true, "files": [ /* file objects: id, url, filename, size, ... */ ], "is_uploading": false, "file_count": 3, "expected_file_count": 3, "total_size": 6291456, "human_total_size": "6 MB" }
POST/collection/{id}/readyOwner only60/min

Mark the collection as ready for download. Not usually needed - collections auto-ready once expected_file_count is reached.

DELETE/collection/{id}Owner only60/min

Delete a collection and all its files.

POST/collection/{id}/passwordOwner only30/min

Set a password on the collection. Requires 4–100 chars.

Request body

FieldTypeDescription
passwordstring · required4–100 chars.
Request
curl -X POST https://storage.to/api/collection/ABC123xyz/password \ -H "X-Visitor-Token: abc123" \ -d '{ "password": "hunter22" }'
DELETE/collection/{id}/passwordOwner only30/min

Remove the password from a collection.

POST/collection/{id}/verify-password10/min

Check a password. Returns 200 on success, 401 on incorrect password.

Request body

FieldTypeDescription
passwordstring · required
POST/collection/{id}/expiryOwner only30/min

Change a collection's expiration.

Request body

FieldTypeDescription
daysinteger · optional1–7 days from now. Omit or null for permanent (premium only).
POST/collection/{id}/max-downloadsOwner only30/min

Set a download cap (burn-after-N-downloads). Collection auto-deletes when reached.

Request body

FieldTypeDescription
max_downloadsinteger · optional1–1000. Must exceed current download count. null to remove the cap.

Files

All file-level settings (password, expiry, max-downloads) mirror the collection endpoints. Owner-only.

GET/file/{id}/status120/min

Check whether a file is still pending its upload.

Response
{ "pending": false }
DELETE/file/{id}Owner only60/min

Delete a file immediately.

POST/file/{id}/thumbnailOwner only120/min

Upload a thumbnail image for a video or image file (used on the download page). Max 2 MB.

Request body

FieldTypeDescription
thumbnailimage · requiredMultipart upload. Max 2 MB.
Response
{ "success": true, "thumbnail_url": "https://..." }
POST/file/{id}/passwordOwner only30/min

Set a password on a file. Requires 4–100 chars.

DELETE/file/{id}/passwordOwner only30/min

Remove a file's password.

POST/file/{id}/verify-password10/min

Verify a file's password.

POST/file/{id}/expiryOwner only30/min

Change a file's expiration.

Request body

FieldTypeDescription
daysinteger · optional1–7 days from now. Omit or null for permanent (premium only).
POST/file/{id}/max-downloadsOwner only30/min

Cap a file's total downloads. Auto-deletes when reached.

ShareX upload

One-shot upload endpoint - send a multipart file, get a shareable URL back. No init/confirm dance. Ideal for screenshot tools. Full setup guide at /docs/sharex.

POST/sharex/upload20/day

Upload an image or file directly (multipart form, file field). Max 25 MB.

Request
curl -X POST https://storage.to/api/sharex/upload \ -F "file=@screenshot.png"
Response
{ "success": true, "url": "https://storage.to/FQxyz1234", "filename": "screenshot.png", "expires_at": "2026-04-15T12:00:00Z" }

Desktop auth

For authenticated clients (e.g. the desktop app) holding a Sanctum token.

GET/userBearer token

Return the authenticated user.

Request
curl https://storage.to/api/user \ -H "Authorization: Bearer <token>"
Response
{ "id": 42, "name": "Ada", "email": "ada@example.com", "is_premium": true }
POST/auth/logoutBearer token

Revoke the current access token.

Misc

GET/health

Liveness check. Returns 200 with { "status": "ok" } when the API worker is serving.

Response
{ "status": "ok" }
GET/activity

Live activity stream for the homepage globe. Cached at Cloudflare's edge.

GET/bandwidth/status60/min

Current upload quota usage for the caller - used by the CLI and desktop app to show remaining capacity. Response shape differs for authenticated users. Despite the URL name, this tracks upload bytes only; downloads aren't counted.

Response · anonymous
{ "success": true, "authenticated": false, "has_token": true, "limit_bytes": 107374182400, "limit_gb": 100, "used_bytes": 12345678, "used_gb": 0.01, "remaining_bytes": 107361836722, "remaining_gb": 99.99, "window_hours": 24 }
Response · authenticated
{ "success": true, "authenticated": true, "plan": "premium" }
POST/app-analytics120/min

Submit a usage event from the CLI or desktop app.

Request body

FieldTypeDescription
appstring · requireddesktop, cli, or web.
versionstring · optionalClient version.
eventstring · requiredEvent name, e.g. upload_complete.
contextobject · optionalExtra metadata.
POST/app-errors60/min

Submit an error report from the CLI or desktop app. Deduplicated server-side - max 10 of the same error per hour.

Request body

FieldTypeDescription
appstring · requireddesktop, cli, or web.
typestring · requiredError class/type.
messagestring · requiredError message.
stackstring · optionalStack trace.
version, os, os_version, arch, contextvarious · optionalDiagnostic metadata.