File storage
Uploads, photos and documents with signed, expiring upload links — in the same project as sign-in.
Profile pictures, receipts, PDFs — whatever your users send. Storage lives in the same project as sign-in, so a file can belong to a person without you wiring two services together.
How an upload works
Your app asks the project API for an upload URL, then the file goes straight to storage — it does not pass through your server:
POST /v1/projects/<slug>/storage/upload-url → { url, key, method: "PUT", expiresIn: 900 }
PUT <the returned url> → the file itself
The request body declares what you intend to receive — filename, contentType, size in bytes, and an optional prefix (default uploads/). The response's url is where the client PUTs the file, key is the storage key it lands at, and expiresIn is how many seconds the URL stays valid.
The URL is bound to what you declared, and the binding is enforced at the sink:
- It expires after 900 seconds and works once — a successful PUT burns the token.
- A declared
sizeis enforced byte-for-byte; a different length is rejected. - A declared
contentTypeis enforced against the request and, for images and PDFs (JPEG, PNG, GIF, WebP, PDF), checked against the file's real bytes — not just the extension the caller claimed. - Files larger than the folder's configured limit are refused before the URL is even issued.
So a leaked upload URL expires fast, and a renamed executable cannot pose as a photo.
The storage endpoints
Everything lives under /v1/projects/<slug>/storage, with your console session or a project API key:
| Method | Path | Scope | What it does |
|---|---|---|---|
GET | /storage/stats | storage:read | File count, total bytes, and the project prefix |
GET | /storage/files?prefix=… | storage:read | List keys under a prefix — up to 200 per call, with a truncated flag |
POST | /storage/upload | storage:write | Multipart upload (file field plus a path) through the API — for server-side files |
POST | /storage/upload-url | storage:write | Mint the signed PUT URL described above |
DELETE | /storage/files/<path> | storage:write | Delete one object |
Files are also listed, uploaded and deleted from the Storage page in the console, which uses these same routes.
Where files live
Every file lands under your project's prefix on the object store — R2_PREFIX is injected into your app's environment at deploy time, so server code can always tell where its files are. Keys returned by the API (uploads/photo.png, say) are relative to that prefix.
A bucket per environment
Previews write to their own bucket. A test upload on a branch cannot appear in your live app, and the preview's files are discarded with the branch.
Private by default
There is no public URL for an object. Upload URLs are signed and expire; the same posture applies on the way out — your app decides what to serve and to whom, and shares links that stop working rather than a bucket anyone can browse.
Limits
Storage is included in the $20 plan up to the launch cap, and heavier use draws prepaid credits — the same wallet as AI, so a spike stops at zero rather than billing your card.
Under the hood: object storage on Cloudflare R2 — each environment and preview branch gets its own key prefix under the project's bucket.