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 R2PUT 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

Most endpoints don't require authentication. Anonymous uploads are a core feature.

Authentication is optional and unlocks:

  • 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

We use Laravel Sanctum bearer tokens. Issue a token via the desktop OAuth handoff or the web login, then send it as:

Authorization: Bearer <token>

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.

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/init 60/min

Initiate an upload. For files >50 MB the response includes part_urls for a multipart upload; otherwise a single url.

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, "url": "https://r2.cloudflarestorage.com/...signed...", "r2_key": "uuid-abc123", "upload_id": null, "is_multipart": false }
Response · multipart
{ "success": true, "upload_id": "01HXYZ...", "r2_key": "uuid-abc123", "is_multipart": true, "part_size": 52428800, "part_urls": [ { "partNumber": 1, "url": "https://..." }, { "partNumber": 2, "url": "https://..." } ] }
POST /upload/parts 120/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-multipart 500/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/abort 60/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/confirm 60/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", "raw_url": "https://storage.to/r/FQxyz1234", "filename": "report.pdf", "size": 2202009, "human_size": "2.1 MB", "expires_at": "2026-04-15T12:00:00Z" } }
POST /file/reserve 60/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", "raw_url": "https://storage.to/r/FQxyz1234", "expires_at": "2026-04-12T18:00:00Z" } }
POST /upload/init-batch 500/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-batch 500/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 /collection 30/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" } }
GET /collection/{id}/status 120/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}/ready Owner only 60/min

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

DELETE /collection/{id} Owner only 60/min

Delete a collection and all its files.

POST /collection/{id}/password Owner only 30/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}/password Owner only 30/min

Remove the password from a collection.

POST /collection/{id}/verify-password 10/min

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

Request body

FieldTypeDescription
passwordstring · required
POST /collection/{id}/expiry Owner only 30/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-downloads Owner only 30/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}/status 120/min

Check whether a file is still pending its upload.

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

Delete a file immediately.

POST /file/{id}/thumbnail Owner only 120/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}/password Owner only 30/min

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

DELETE /file/{id}/password Owner only 30/min

Remove a file's password.

POST /file/{id}/verify-password 10/min

Verify a file's password.

POST /file/{id}/expiry Owner only 30/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-downloads Owner only 30/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/upload 20/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 "[email protected]"
Response
{ "success": true, "url": "https://storage.to/FQxyz1234", "raw_url": "https://storage.to/r/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 /user Bearer token

Return the authenticated user.

Request
curl https://storage.to/api/user \ -H "Authorization: Bearer <token>"
Response
{ "id": 42, "name": "Ada", "email": "[email protected]", "is_premium": true }
POST /auth/logout Bearer token

Revoke the current access token.

Misc

GET /health

Health check. Pings the database, R2 storage, and Redis cache. Returns 200 if all green, 503 otherwise.

Response
{ "status": "healthy", "checks": { "database": "ok", "storage": "ok", "cache": "ok" }, "timestamp": "2026-04-12T12:00:00Z" }
GET /activity

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

GET /bandwidth/status 60/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-analytics 120/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-errors 60/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.