Skip to content
Developer docs

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
  1. Grant. Your server calls POST /api/site/v1/files/uploads with the Site Key, server to server, and gets back the file's id and a one-time upload address.
  2. Upload. The browser sends the bytes with PUT to upload.url, with upload.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 answers 200 with the file already ready.
  3. 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)

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.

src/server/routes/files.ts
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.

POST/api/site/v1/files/{fileId}/url
POST /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: true makes 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/usage returns usedBytes, limitBytes (0 = no pack, null = no limit), onExceed (always block) and maxFileBytes, 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.
WhatLimit
One file100 MB
File name200 characters
Upload addressone upload, 15 minutes
Unconfirmed grantremoved after 1 hour
Signed link60 seconds to 7 days, default 1 hour
Files per list page100

Other calls

CallDoes
GET /api/site/v1/filesReady files, newest first (?before=<id>&limit=).
GET /api/site/v1/files/:fileIdOne file.
POST /api/site/v1/files/:fileId/deleteDeletes the file for good, and frees its storage.

Error codes

CodeStatusWhen
E_STORAGE_OFF403The owner hasn't turned Storage on for this site.
E_STORAGE_UNAVAILABLE403Storage can't be used on this account yet.
E_STORAGE_NEEDS_PACK402The account has no storage pack, so Storage can't be turned on and uploads are refused.
E_STORAGE_LIMIT402There isn't room left in the storage packs for this file. Nothing was created.
E_FILE_TYPE422The type isn't on the allow-list.
E_FILE_EMPTY422The size is 0.
E_FILE_TOO_LARGE422Over 100 MB.
E_BODY_TOO_LARGE413At the upload: more bytes than the grant allows.
E_FILE_SIZE_MISMATCH422At the upload: the bytes received don't match the size in the grant.
E_UPLOAD_TOKEN403 / 409At the upload: the address isn't valid (403) or was already used (409). Ask for a new grant.
E_UPLOAD_EXPIRED410At the upload: more than 15 minutes after the grant. Ask for a new one.
E_FILE_NOT_UPLOADED409Confirmed before the bytes arrived.
E_FILE_NOT_READY409A link was asked for a file that isn't ready yet.
E_FILE_NOT_FOUND404No such file on this site (or on this side: live or staging).
For agencies

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.