Modulify

Storage HTTP API

The nine endpoints your site and your integrations use to read and write storage.

On this page

Your project's storage is a small REST API hosted on your own CDN link. Every method is a POST with a JSON body and a storage key header. A public file you write is then readable by anyone at the same host; a private one is readable only through /get, or through a short lived link from /signed-url.

Your own base URL, and a copy of this reference with that host filled in, live in the editor's Storage tab under Configuration. Expand Using your storage, switch to the External tab, and use Copy guide to take the whole thing away as plain text.

Authentication

Send the key as an X-Storage-Key header on every request.

curl -X POST https://your-cdn-link/list \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":""}'

Keys start with msk_. Your site reads its own key from process.env.CDN_PRIVATE_KEY and the base URL from process.env.CDN_URL. Both are server side only. A missing, malformed or unknown key returns 401 with "A valid storage key is required!"

A key is scoped to exactly one project's storage. Rotating it from the Storage panel invalidates the old one immediately.

Response envelope

Every response, success or failure, has the same shape:

{
    "success": true,
    "message": "Object stored successfully.",
    "data": { "key": "notes/hello.txt", "url": "https://your-cdn-link/notes/hello.txt", "size": 8, "visibility": "public", "staleKept": false },
    "code": 200
}

Treat a call as failed unless the HTTP status is ok and success is true. Read the payload from data.

Endpoints

Method Path Body Returns
POST /put { key, body, contentType, encoding, visibility } { key, url, size, visibility, staleKept }
POST /get { key } { key, contentType, encoding, body, size, visibility }
POST /list { prefix, token } { folders, folderDetails, files, nextToken, rootVisibility }
POST /delete { key } or { keys } { deleted, missing }, or { deleted, missing, failed, failedKeys } when some could not be deleted
POST /create-folder { name, path, visibility } { name, visibility }
POST /delete-folder { path } { deleted, complete }
POST /move { keys, destination } { moved, skipped, failed, missing, tooLarge, publicItems, privateItems, destinationVisibility }
POST /set-visibility { keys, visibility } { changed, skipped, failed, tooLong, complete, visibility }
POST /signed-url { key, download } { key, url, expiresAt, visibility }

/put

Create or overwrite a file. body must be a string. Set encoding to base64 for binary data, otherwise it is treated as UTF-8. contentType is optional and is guessed from the key when omitted. visibility is optional too: public or private. When it is omitted, overwriting a file that already exists keeps whatever visibility that file has, and a new file follows its folder, or the top level default outside any folder. Passing it explicitly moves an existing file to that side and removes the old copy. The response says which one applied, and url is null for a private file because it has no public address.

A visibility other than public or private returns 400 with "A visibility of public or private is required!" Send null or an empty string, or leave the field out, to mean no visibility at all. A key whose filename is one of the nine endpoint names, list or notes/move for example, returns 400 too, see Key rules.

curl -X POST https://your-cdn-link/put \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"notes/hello.txt","body":"hi there"}'

Writing to a key that already exists replaces it and purges that object from the CDN cache, so the next fetch returns the new bytes. A key only ever holds one file, so a write that lands on the other side removes the old copy there. If that removal fails, the file is still stored and the request still answers 200, but staleKept is true and the message reads "Object stored, but an older public copy could not be removed. Store the object again to finish." (or private for the other side). Until you do, the older copy stays where it was, so an older public copy is still served at its CDN address. Send the same /put again without visibility to finish: the newer copy wins and the older one is removed. staleKept is false on every other write.

/get

Read a file back, public or private. The body always comes back base64 encoded, whatever the file is, so encoding in the response is always base64, and visibility tells you which kind of file you got. A file with a copy on both sides reads as its public copy, the same one /list labels and /signed-url links to.

curl -X POST https://your-cdn-link/get \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"notes/hello.txt"}'

A missing key returns 404 with "No object exists at that key!"

/get returns files up to 25 MB. A larger file returns 413 with "The object is too large to fetch through /get, request a link from /signed-url instead!", so read it through /signed-url. Keep in mind that the whole file travels inside the JSON response as base64, a third bigger than the file itself, so /signed-url is also the lighter choice for anything more than a few megabytes.

/list

List one level of the bucket. folders is an array of folder names relative to the prefix, folderDetails repeats them as { name, visibility, mixed } where visibility is the default new files in that folder get and mixed means the folder holds files on the other side of that default, and files is an array of { key, name, size, visibility, url } with url set to null for a private file. rootVisibility is the default a new file gets at the top level, public or private, and comes back on every page whatever the prefix. Results come back fifty at a time.

curl -X POST https://your-cdn-link/list \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"notes/"}'

When nextToken is not null, pass it back as token to get the next page. Keep going until it is null.

A token that cannot be read, for example one that was edited or truncated on the way, returns 400 with "The page token is not valid!", so start again from the first page without a token.

/delete

Delete one file with key, or several with keys.

curl -X POST https://your-cdn-link/delete \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keys":["notes/hello.txt","notes/old.txt"]}'

At most 1000 keys per request. Above that you get a 400 saying so.

A successful request answers { deleted, missing }: deleted is the number of keys removed and missing the number that had no file to remove, so sending the same key twice counts it in deleted the first time and in missing the second. A request whose keys were all already gone still succeeds, with deleted at 0 and "The objects were already gone." When some keys could not be deleted the request answers 500 with success set to false and "Some objects could not be deleted!", and data carries deleted, missing, failed and failedKeys. Everything counted in deleted is gone, and only the keys listed in failedKeys are still there, so send just those again.

/create-folder

Create an empty folder. name is the folder, path is where to put it, empty for the root. visibility is optional, public or private, and sets the folder's own default for files uploaded into it later. Leave it out, or send null or an empty string, and a new folder takes its default from its parent folder, or from the top level default at the root. visibility in the response is the default the folder has afterwards.

curl -X POST https://your-cdn-link/create-folder \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"invoices","path":""}'

Creating a folder that already exists is safe. Without visibility it keeps the default it already has. With visibility, that becomes its default and the folder keeps exactly one setting, but nothing already inside moves; send the folder key to /set-visibility to move its files too.

A name that is empty once cleaned returns 400 with "A valid folder name is required!" A visibility other than public or private returns 400 with "A visibility of public or private is required!"

/delete-folder

Delete a folder and everything inside it.

curl -X POST https://your-cdn-link/delete-folder \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path":"invoices"}'

One request removes at most 20,000 files from each side of the bucket. deleted counts what this request removed, and complete is false when the folder still holds more, so send the same request again until complete is true.

The folder's visibility setting goes with it. A file written into the same path afterwards follows its parent folder's default, or the top level default when no parent has one. Pass visibility on each /put when the files there have to stay private.

/move

Move or rename files. destination is normally a folder, empty for the root, and every key in keys lands inside it under its own name. End it with / to be sure it is read as a folder: archive/ moves notes/hello.txt to archive/hello.txt.

A request with exactly one file key and a destination that does not end in / is a rename, and the destination is then the file's whole new key, folders included. {"keys":["notes/hello.txt"],"destination":"notes/hi.txt"} renames the file in place, and {"keys":["notes/hello.txt"],"destination":"archive"} renames it to a top level file called archive rather than moving it into a folder of that name, which is why a folder destination should always carry its trailing slash. Folders are never renamed: a key ending in / is always moved into the destination and keeps its own name, and a request with two or more keys reads a slashless destination as a folder too.

curl -X POST https://your-cdn-link/move \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keys":["notes/hello.txt"],"destination":"archive/"}'

The response splits the outcome into counts. moved is the objects that moved. skipped is the keys left alone because something already sits at the destination, the key is already there, another key in the same request already claimed that destination, a folder would move inside itself, a folder or something inside it would pass the key length limits at the destination (see Key rules), or the new name is the reserved .modulify-keep. failed is the keys whose copy errored, with the original left in place. missing is the keys that no longer exist, so there was nothing to move.

One request moves at most 5,000 objects. A request with more than 5,000 different keys returns 400 with "One move takes at most 5000 files and folders. Move them in smaller groups!" before anything moves. The files a request names count toward the limit first, wherever they sit in keys, so a folder that would take the request past it is left where it is, before any of its files move, and counted in tooLarge. The message then reads "One or more folders were left where they are, because moving them would take this move past 5000 files. Move them on their own or in smaller groups." Send that folder in a request of its own, or move its contents in smaller requests.

A move never changes visibility; use /set-visibility for that. destinationVisibility is the default of the folder the files landed in, and publicItems and privateItems count the moved keys on each side, a folder counted once by its own default. When destinationVisibility is private and publicItems is above zero, those files are still public. A destination longer than the key limit, or one whose folder path is longer than the folder limit, is refused with a 400 before anything moves, and so is a rename whose new filename is one of the nine endpoint names, see Key rules.

/set-visibility

Make files public or private. A key ending in / changes a whole folder: everything inside it moves to that side, and the folder's own default becomes that visibility, so files added later follow it, and so do subfolders that have no default of their own. A plain key changes one file. When no file has that key but a folder does, the plain key changes that whole folder instead, exactly as if it had ended in /. The folder names /list returns are relative to the prefix and have no trailing slash, so build a folder key as the prefix, the name and a /.

curl -X POST https://your-cdn-link/set-visibility \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keys":["invoices/"],"visibility":"private"}'

visibility must be public or private, otherwise you get a 400 with "A visibility of public or private is required!" An empty keys returns 400 with "At least one key is required!", and more than 200 keys returns 400 with "You can change at most 200 items per request!", so split a longer list into several requests. Making a file public opens its CDN address to anyone; making it private stops that address working right away.

The response counts what happened. changed is the files moved to the requested side. skipped is the keys that were already there or do not exist. failed is the files that could not be moved and kept their old visibility. When failed is above zero the request answers 500 with success set to false and "Some items could not change visibility and may still be on their old side, please try again!", but data still carries all the counts, because everything counted in changed did move. Send the same request again to retry the rest. tooLong is the keys left alone because they pass the key length limits: a file key longer than 1016 bytes, or a folder whose path is longer than 1001 bytes and so has no room for the .modulify-keep file that holds its default, in which case nothing inside it moves. When tooLong is above zero, nothing failed and complete is true, the request answers 400 with "The object key is too long!", again with every count in data. Move that folder to a shorter path, then change its visibility. A single file inside it can still be changed on its own. A file found with a copy on both sides, for example after an interrupted change, keeps its newest copy on the side you asked for, loses the other one, and counts as changed.

One request works through at most 20,000 files. When complete is false a folder still holds files on the other side, so send the same request again until complete is true.

/signed-url

Get a temporary link to any file, public or private, whatever its size. Use it to serve private files and files too large for /get: check who is asking in your own server code, then redirect them to the link.

curl -X POST https://your-cdn-link/signed-url \
  -H "X-Storage-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"invoices/2026-001.pdf","download":true}'

url works for fifteen minutes with no key and no headers, and expiresAt is the moment it stops working. Send download as true to make the link a save-as download named after the file, otherwise the browser opens the file. Anyone holding the link can open the file until then, so hand it only to the person who asked, never store it, and never put it in a page. visibility says which kind of file the link points at.

A missing key returns 404 with "No object exists at that key!"

Reading files publicly

Public reads do not go through this API. Every public file is served straight from your CDN link at its own key:

curl https://your-cdn-link/uploads/logo.png

No key, no headers. That is what makes it good for images and downloads. A private file is not there: the same address answers 404, and the only ways to its bytes are /get and /signed-url, called with the storage key from your server code.

Limits

Limit Value
Object size on /put 100 MB
Request body on /put 140 MB
Request body on every other endpoint 2 MB
Object size on /get 25 MB
Link lifetime from /signed-url 15 minutes
Object key length 1016 bytes
Folder path length 1001 bytes
Keys per /delete 1000
Keys per /set-visibility 200
Files worked through per /set-visibility request 20,000
Objects per /list page 50
Total storage per site 500 GB, a soft limit

Exceeding a body limit returns 413 with "The request body is too large." An object over the /put size limit returns 413 with "The object exceeds the storage upload size limit!" The 500 GB per site is the only line no endpoint enforces: nothing refuses a /put for crossing it, it is the amount a site is expected to stay under, and it can be raised on request.

Key rules

Keys are paths, with / separating folders. A backslash is read as a folder separator too, so notes\hello.txt is stored as notes/hello.txt, and leading slashes, empty segments and . and .. segments are stripped before the key is used, so ../../etc/passwd cannot escape your bucket. A key that reduces to nothing is rejected with "A valid object key is required!"

.modulify-keep is reserved. It is the marker file used to keep empty folders alive, so it is rejected as a key on /put, as a folder name on /create-folder and as a rename target on /move.

The nine endpoint names are reserved as filenames too: put, get, list, delete, create-folder, delete-folder, move, set-visibility and signed-url. The CDN routes any path ending in one of them to this API, so a file called list would never be served at its own address, whichever folder it sat in. The rule looks at the last segment of the key only, in any folder and at any depth, and ignores case, so notes/list, LIST and a/b/c/Move are all refused, while list.json, listing and list/notes.txt are fine. /put answers 400 with "A file cannot be named put, get, list, delete, create-folder, delete-folder, move, set-visibility, signed-url, because the CDN routes those paths to the storage API. Pick another name!", and so does a single file rename through /move whose new name is one of them, before anything moves. Folders are exempt: /create-folder accepts those names, and a folder key ending in / is never checked, because a folder has no address of its own.

A key is measured in bytes, and the limit leaves room for the public/ or private/ prefix a file is stored under. A longer key returns 400 with "The object key is too long!"

A folder is kept as a .modulify-keep file inside it, and that file has to fit the same limit, so a folder path can be at most 1001 bytes. That holds for a folder made with /create-folder, where the path and name together can be at most 1001 bytes, and just as much for a folder that only exists because a file was written into it. So /put refuses a key whose folder path is longer than 1001 bytes with the same 400, even when the key itself is short enough, and /move refuses such a destination the same way. /move counts a folder as skipped, and moves nothing inside it, when its new path or the path of any folder inside it would be longer than 1001 bytes, or any file inside it would end up longer than 1016 bytes. /set-visibility leaves a folder that long unchanged and counts it in tooLong.

From your site

Modulify sites ship with a typed server only wrapper over these endpoints at src/lib/modulify/storage, so you rarely call them by hand. See Storage for that helper and the panel it belongs to.

Next

  • Storage covers the panel, the CDN link and key rotation.
  • Site databases is the other half of your project's persistence.