File storage
Let a site's visitors and staff upload photos, documents and other files, and keep them safely with the site. The owner adds a storage pack and turns Storage on in the site's Storage tab. App sites only.
How it works
An upload takes three steps. Your server asks for permission, the browser sends the bytes, and your server confirms. The Site Key stays on your server the whole time.
browser ── 1. "I want to upload x.jpg, 2.4 MB" ──▶ your /site-api route
your route ── POST /api/site/v1/files/uploads (Site Key) ──▶ EzyStudio ── grant: file id + upload.url
browser ── 2. PUT <upload.url> (the bytes, same origin) ──▶ the site's own address ── 200, file ready
your route ── 3. POST /api/site/v1/files/:fileId/complete ──▶ EzyStudio ── confirmed: save the id- Grant. Your server calls
POST /api/site/v1/files/uploadswith the Site Key, server to server, and gets back the file's id and a one-time upload address. - Upload. The browser sends the bytes with
PUTtoupload.url, withupload.headers. The address is a path on the site's own address, so the request is same-origin. The platform passes the bytes into storage and answers200with the file alreadyready. - Confirm. Your server calls
POST /api/site/v1/files/:fileId/complete. It checks the stored file and returns it. Then save the file's id on your own record.
The upload address is relative: /__platform/files/uploads/<token>, valid for 15 minutes and good for one upload. The site's security headers need no change, and there is no bucket setting to touch. To upload from server code instead of a browser, put SITE_URL in front of it.
Examples
1. Ask for a grant (your server)
curl -X POST "$SITE_URL/api/site/v1/files/uploads" \
-H "Authorization: Bearer $SITE_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "salon-front.jpg", "contentType": "image/jpeg", "size": 2516582}'
201 {"status": 201, "data": {
"file": {"id": "file_01j…", "name": "salon-front.jpg", "contentType": "image/jpeg",
"size": 2516582, "visibility": "private", "status": "pending",
"environment": "production", "createdAt": "…", "completedAt": null},
"upload": {"method": "PUT", "url": "/__platform/files/uploads/<token>",
"headers": {"Content-Type": "application/octet-stream"},
"expiresAt": "…"}}}name (up to 200 characters), contentType, size in bytes (exact), and visibility: private (the default) or public.
2. Send the bytes (the browser)
// In the browser: upload.url is a path on the site's own address (same origin).
const res = await fetch(grant.upload.url, {
method: grant.upload.method, // "PUT"
headers: grant.upload.headers, // {"Content-Type": "application/octet-stream"}
body: file, // the picked File: exactly grant.file.size bytes
});
if (!res.ok) throw new Error((await res.json()).errors?.[0]?.code);
const { data } = await res.json(); // data.file.status === "ready"3. Confirm (your server)
curl -X POST "$SITE_URL/api/site/v1/files/file_01j…/complete" \
-H "Authorization: Bearer $SITE_KEY"
200 {"status": 200, "data": {"file": {"id": "file_01j…", "status": "ready", …}}}A route in your site's backend
The app template has helpers for every step: createUpload(), completeUpload(), fileUrl() and deleteFile() in src/platform/files.ts (server only), and putFile() in src/platform/files-browser.ts for the browser. Both files are managed by the platform: never edit them.
import {Hono} from 'hono';
import {completeUpload, createUpload} from '../../platform/files';
export const files = new Hono()
// 1. The browser asks. Check who the visitor is, then ask for a grant.
.post('/uploads', async c => {
const {name, contentType, size} = await c.req.json();
const res = await createUpload(c.req.url, {name, contentType, size});
if (!res.ok) return c.json({code: res.code, message: res.message}, res.status);
return c.json({fileId: res.data.file.id, upload: res.data.upload}); // never the Site Key
})
// 3. After the browser's PUT: confirm, then save the id on your own record.
.post('/uploads/:id/complete', async c => {
const res = await completeUpload(c.req.url, c.req.param('id'));
if (!res.ok) return c.json({code: res.code, message: res.message}, res.status);
// await db.update(bookings).set({photoId: res.data.id}) …
return c.json({file: res.data});
});Private files
Files are private unless you say otherwise. To show or download one, your server asks for a signed link and hands it to the visitor who may see it. Links expire, so ask for a fresh one each time.
/api/site/v1/files/{fileId}/urlPOST /api/site/v1/files/file_01j…/url
Authorization: Bearer <SITE_KEY>
Content-Type: application/json
{"expiresIn": 600, "download": true}
200 {"status": 200, "data": {"url": "https://…", "expiresAt": "…"}}expiresIn: 60 to 604,800 seconds (7 days). Default 3,600 (one hour).download: truemakes the browser save the file instead of showing it.
Public files
Upload with "visibility": "public" and the file gets a stable link on the site's own address: /__platform/files/<id>/<name>. Use it for things anyone may see, like a product photo or a price list. Each visit is sent on to a fresh, short-lived link (a 302), so the stable link never exposes storage.
Security
- The Site Key never goes to the browser. Only your server asks for grants, links and deletes. Check who the visitor is before you ask.
- One upload per grant. The upload address works once, for 15 minutes, for exactly the size you asked for. The platform counts the bytes as they arrive.
- Private by default. A file is public only when you ask for it.
- Only safe types. Images (SVG too, which always downloads and never opens in the page), PDF, plain text, CSV, JSON, ZIP, audio, video, and Word, Excel and PowerPoint files (docx, xlsx, pptx). Never HTML or JavaScript.
- 100 MB per file.
- Unfinished uploads are removed. A grant that isn't confirmed within an hour is deleted.
Limits and storage
- Storage is an add-on pack. No plan includes it. EzyStudio's packs are $5 a month for 100 GB, $25 for 500 GB and $49 for 1 TB; they add up, and all the account's sites share them. Without a pack, Storage can't be turned on and an upload is refused with
402 E_STORAGE_NEEDS_PACK. - At the packs' total, uploads stop on every plan with
402 E_STORAGE_LIMIT, until files are deleted or a pack is added. Nothing is ever billed past it. - On an agency's own bucket, the agency decides: free for all its clients, part of its plans, or its own packs.
GET /api/site/v1/files/usagereturnsusedBytes,limitBytes(0 = no pack, null = no limit),onExceed(alwaysblock) andmaxFileBytes, so your page can warn before a large upload.- Files uploaded with the editor's key (the Preview) are staging files. They are kept apart from the live site's files, and each side only sees its own.
| What | Limit |
|---|---|
| One file | 100 MB |
| File name | 200 characters |
| Upload address | one upload, 15 minutes |
| Unconfirmed grant | removed after 1 hour |
| Signed link | 60 seconds to 7 days, default 1 hour |
| Files per list page | 100 |
Other calls
| Call | Does |
|---|---|
GET /api/site/v1/files | Ready files, newest first (?before=<id>&limit=). |
GET /api/site/v1/files/:fileId | One file. |
POST /api/site/v1/files/:fileId/delete | Deletes the file for good, and frees its storage. |
Error codes
| Code | Status | When |
|---|---|---|
E_STORAGE_OFF | 403 | The owner hasn't turned Storage on for this site. |
E_STORAGE_UNAVAILABLE | 403 | Storage can't be used on this account yet. |
E_STORAGE_NEEDS_PACK | 402 | The account has no storage pack, so Storage can't be turned on and uploads are refused. |
E_STORAGE_LIMIT | 402 | There isn't room left in the storage packs for this file. Nothing was created. |
E_FILE_TYPE | 422 | The type isn't on the allow-list. |
E_FILE_EMPTY | 422 | The size is 0. |
E_FILE_TOO_LARGE | 422 | Over 100 MB. |
E_BODY_TOO_LARGE | 413 | At the upload: more bytes than the grant allows. |
E_FILE_SIZE_MISMATCH | 422 | At the upload: the bytes received don't match the size in the grant. |
E_UPLOAD_TOKEN | 403 / 409 | At the upload: the address isn't valid (403) or was already used (409). Ask for a new grant. |
E_UPLOAD_EXPIRED | 410 | At the upload: more than 15 minutes after the grant. Ask for a new one. |
E_FILE_NOT_UPLOADED | 409 | Confirmed before the bytes arrived. |
E_FILE_NOT_READY | 409 | A link was asked for a file that isn't ready yet. |
E_FILE_NOT_FOUND | 404 | No such file on this site (or on this side: live or staging). |
Your clients' files go to your own S3-compatible bucket, which you connect in your agency settings under Storage. The API is the same, so a site needs no change.
Every endpoint with full request and response schemas is in the API reference. The other Site API calls are on the Site API page.