Uploading and downloading
The storage REST API for your app's users, with uploads, large files, links, folders and deletes.
Your app's users reach storage at /{appUid}/storage, with the same bearer token they use for
your tables: a user's access token, or an agent API key. Every route is authorized against your
app's policies (see Who can do what), and every file, folder and
storage is named by its uid.
| Method | Path | Policy key | Does |
|---|---|---|---|
GET | /storage | storage:read_list | List the app's storages |
GET | /storage/{storageUid}/files | storage:list | List one folder |
POST | /storage/{storageUid}/files | storage:upload | Upload files |
POST | /storage/{storageUid}/presign | storage:presign | Get direct-upload targets for large files |
POST | /storage/{storageUid}/confirm | storage:confirm | Record files uploaded directly |
POST | /storage/{storageUid}/folders | storage:create_folder | Create a folder |
GET | /storage/files/{fileUid} | storage:read | A file's details |
GET | /storage/files/{fileUid}/url | storage:read | A link to the file |
GET | /storage/files/{fileUid}/content | storage:read | The file's bytes |
PATCH | /storage/files/{fileUid} | storage:update | Rename, move, change metadata or owner |
DELETE | /storage/files/{fileUid} | storage:delete | Delete a file |
All paths begin with /{appUid}. The examples use these variables:
export AUTHORIC_URL="https://api.authoric.ai" # your API's address
export APP_UID="<your app uid>"
export TOKEN="<an access token>"
export STORAGE_UID="<a storage uid>"List storages
curl "$AUTHORIC_URL/$APP_UID/storage" \
-H "Authorization: Bearer $TOKEN"{
"storages": [
{
"uid": "0199b901-3c5d-7a2e-8f41-6b9d0e2c7a15",
"name": "Invoice scans",
"visibility": "private",
"max_file_bytes": 20971520,
"allowed_mime_types": ["application/pdf", "image/*"],
"status": "active",
"usedBytes": 48213004,
"fileCount": 312,
"folderCount": 14,
"created_at": "2026-09-02T10:14:00.000Z",
"updated_at": "2026-09-02T10:14:00.000Z",
"deleted_at": null
}
]
}usedBytes, fileCount and folderCount count only what the caller can see: its own
organization's files, not every organization's.
List a folder
Name the folder by path or by folderUid; with neither you get the storage's root. A path
that does not exist is a 404. Listing never creates folders.
curl "$AUTHORIC_URL/$APP_UID/storage/$STORAGE_UID/files?path=/2026/q3&limit=100" \
-H "Authorization: Bearer $TOKEN"{
"storageUid": "0199b901-3c5d-7a2e-8f41-6b9d0e2c7a15",
"folder": { "uid": "0199b903-7e1a-7c4b-9d20-5f3a8b6c1e47", "storage_uid": "0199b901-3c5d-7a2e-8f41-6b9d0e2c7a15", "parent_uid": "0199b902-1b8f-7e6d-a3c5-2d9e0f4a7b18", "name": "q3", "path": "/2026/q3", "depth": 1 },
"folders": [],
"files": [
{
"uid": "0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73",
"storage_uid": "0199b901-3c5d-7a2e-8f41-6b9d0e2c7a15",
"folder_uid": "0199b903-7e1a-7c4b-9d20-5f3a8b6c1e47",
"name": "HAL-4530.pdf",
"mime_type": "application/pdf",
"size": 184320,
"owner_uid": "0199b8f0-6d4e-7b1a-9c2f-3e8a5d7b0c61",
"metadata": { "invoice_uid": "0199b8e4-2a6f-7c31-8d5e-41f0a9c37b12" },
"created_at": "2026-10-24T09:31:44.000Z",
"updated_at": "2026-10-24T09:31:44.000Z",
"deleted_at": null
}
],
"counts": { "folders": 0, "files": 1 },
"paging": { "limit": 100, "offset": 0 }
}Folders and files are each sorted by name and paged with limit (100 by default, at most 1,000)
and offset; counts is the total in the folder. An item the app's owner shared with your organization is marked "sharedByOwner": true (see
sharing).
Upload files
Send a multipart/form-data request with the files under the field name files (up to 20 per
request). Optional form fields say where they go and whose they are:
| Field | Meaning |
|---|---|
path | Destination folder by path, such as /2026/q3. Missing folders are created. |
folderUid | Destination folder by uid. It must exist. |
ownerUid | The user the files belong to. Defaults to the user uploading them. |
With neither path nor folderUid, files go in the storage's root.
curl -X POST "$AUTHORIC_URL/$APP_UID/storage/$STORAGE_UID/files" \
-H "Authorization: Bearer $TOKEN" \
-F "path=/2026/q3" \
-F "files=@HAL-4530.pdf;type=application/pdf"The response is { "uploaded": 1, "files": [ … ] }, each file in the shape shown above. A name
already taken in the folder gets a suffix (HAL-4530 (1).pdf) rather than overwriting.
A single small file can instead be sent as JSON, which is handy from code that has no multipart
support: { "name": "avatar.png", "mimeType": "image/png", "contentBase64": "<base64>", "path": "/avatars" }.
Uploads through this route pass through the API. For large files, upload directly to storage (below).
Upload large files directly
Three steps: ask for a target, PUT the bytes straight to it, then confirm. The bytes never pass
through the API, so size and speed are limited only by your storage.
Ask for upload targets
curl -X POST "$AUTHORIC_URL/$APP_UID/storage/$STORAGE_UID/presign" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "path": "/2026/q3", "files": [ { "name": "scan-batch.zip", "size": 734003200, "mimeType": "application/zip" } ] }'The size and type are checked against the storage's limits now, so a file that would be refused is refused before you upload it.
{
"driver": "s3",
"targets": [
{
"name": "scan-batch.zip",
"folderUid": "0199b903-7e1a-7c4b-9d20-5f3a8b6c1e47",
"key": "<object key>",
"contentType": "application/zip",
"upload": { "url": "https://…", "method": "PUT", "headers": { "Content-Type": "application/zip" }, "expiresIn": 900 },
"proxyUpload": false,
"confirmToken": "<confirm token>"
}
]
}Upload the bytes
PUT the file to upload.url with exactly the upload.headers given, before the target
expires. This request goes to storage, not to the API, so it carries no Authorization header.
When proxyUpload is true (and upload is null), this storage cannot take direct uploads:
use the ordinary upload instead.
Confirm
Tell the API which objects arrived, with the key, confirmToken and folderUid from step 1:
curl -X POST "$AUTHORIC_URL/$APP_UID/storage/$STORAGE_UID/confirm" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "folderUid": "0199b903-7e1a-7c4b-9d20-5f3a8b6c1e47", "files": [ { "key": "<object key>", "confirmToken": "<confirm token>", "name": "scan-batch.zip", "mimeType": "application/zip" } ] }'The API measures each object itself rather than trusting the size you declared. An object over
the per-file limit, or one that would take you past your storage total, is deleted and refused
before any file is recorded. The response is { "confirmed": 1, "files": [ … ] }. Each file may
also carry metadata and an ownerUid.
A key must be one this storage handed out, and can be confirmed once, by whoever asked for it: the
confirmToken ties each target to the user and organization that requested it, and stays valid
for an hour after the upload URL expires.
Get a link to a file
The link follows the storage: permanent for a public storage, signed and expiring for a private one. Ask for a link rather than building URLs yourself.
curl "$AUTHORIC_URL/$APP_UID/storage/files/0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73/url?expiresIn=3600" \
-H "Authorization: Bearer $TOKEN"{
"file": { "uid": "0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73", "name": "HAL-4530.pdf", "mime_type": "application/pdf", "size": 184320 },
"url": "https://…",
"kind": "signed",
"expiresAt": "2026-10-29T15:42:10.000Z"
}kindis"public"(withexpiresAt: null) or"signed".expiresInis in seconds and applies to signed links only. Without it a link lasts 15 minutes.download=1asks for a link that downloads the file rather than showing it in the browser.
The URL needs no token, so it works in an <img src>, an <a href> or an email. That is the
point of it: anyone holding a signed link can open the file until it expires, so hand it only to
the person who asked. A link that has expired, or names a file in a storage that is no longer
public, is a plain 404.
Download the bytes
From a backend that already holds a token, you can stream the file directly. The response carries
the file's type and a Content-Disposition: attachment header with its name.
curl -o HAL-4530.pdf "$AUTHORIC_URL/$APP_UID/storage/files/0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73/content" \
-H "Authorization: Bearer $TOKEN"GET /storage/files/{fileUid} returns the file's details alone, as { "file": { … } }.
Create a folder
Send a path to create a whole chain of folders at once (it succeeds if they already exist), or
a name and an optional parentUid to create one folder:
curl -X POST "$AUTHORIC_URL/$APP_UID/storage/$STORAGE_UID/folders" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "path": "/2026/q4" }'The response is { "folder": { … } }. A name already used by a folder in the same place is a
409. Uploads with a path create folders for you, so you rarely need this route.
Rename, move or change a file
PATCH takes any of:
| Field | Does |
|---|---|
name | Renames the file. A name already taken in its folder is a 409. |
folderUid or path | Moves it to another folder in the same storage. A path is created if missing. |
metadata | Replaces its metadata. Must be a JSON object. |
ownerUid | Gives it to another of your app's users. |
curl -X PATCH "$AUTHORIC_URL/$APP_UID/storage/files/0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "HAL-4530-signed.pdf", "metadata": { "invoice_uid": "0199b8e4-2a6f-7c31-8d5e-41f0a9c37b12", "signed": true } }'The response is { "file": { … } } as it now stands. A file cannot move to another storage or
into another organization's folder.
Delete a file
# Soft delete: hidden everywhere, bytes kept
curl -X DELETE "$AUTHORIC_URL/$APP_UID/storage/files/0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73" \
-H "Authorization: Bearer $TOKEN"
# Hard delete: the bytes are removed too, permanently
curl -X DELETE "$AUTHORIC_URL/$APP_UID/storage/files/0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73?hard=true" \
-H "Authorization: Bearer $TOKEN"{ "type": "file", "uid": "0199b904-5a2c-7f3e-b8d1-4c6e9a0b2d73", "deleted": true, "hard": true, "freedBytes": 184320 }Folders are renamed, moved and deleted in the console or from a handler (sdk.storage); the REST
API works on files.
Errors
| Status | error.code | When |
|---|---|---|
400 | none | A name with a /, a reserved name (. or ..), no file in the request, metadata that is not an object, a move into another storage. |
402 | SUBSCRIPTION_LIMIT_REACHED | The upload would take you past your total storage. |
403 | none | Policy does not grant this action (or isOwner refused another user's file). |
403 | STORAGE_SHARED_READ_ONLY | The app's owner shared this with you: you can open it, not change it. |
404 | STORAGE_NOT_FOUND | No storage with that uid. |
404 | FOLDER_NOT_FOUND | No folder with that uid or path that you can see. |
404 | none | No file with that uid that you can see, including another organization's. |
400 | UPLOAD_NOT_CONFIRMABLE | A confirm without the target's confirmToken, from someone other than who requested it, or after it expired. |
409 | none | A folder name already used in that place, or a direct upload confirmed twice or never uploaded. |
413 | FILE_TOO_LARGE | A file over the per-file limit. |
415 | MIME_NOT_ALLOWED | A file type the storage does not accept. |