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.

MethodPathPolicy keyDoes
GET/storagestorage:read_listList the app's storages
GET/storage/{storageUid}/filesstorage:listList one folder
POST/storage/{storageUid}/filesstorage:uploadUpload files
POST/storage/{storageUid}/presignstorage:presignGet direct-upload targets for large files
POST/storage/{storageUid}/confirmstorage:confirmRecord files uploaded directly
POST/storage/{storageUid}/foldersstorage:create_folderCreate a folder
GET/storage/files/{fileUid}storage:readA file's details
GET/storage/files/{fileUid}/urlstorage:readA link to the file
GET/storage/files/{fileUid}/contentstorage:readThe file's bytes
PATCH/storage/files/{fileUid}storage:updateRename, move, change metadata or owner
DELETE/storage/files/{fileUid}storage:deleteDelete 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:

FieldMeaning
pathDestination folder by path, such as /2026/q3. Missing folders are created.
folderUidDestination folder by uid. It must exist.
ownerUidThe 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.

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"
}
  • kind is "public" (with expiresAt: null) or "signed".
  • expiresIn is in seconds and applies to signed links only. Without it a link lasts 15 minutes.
  • download=1 asks 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:

FieldDoes
nameRenames the file. A name already taken in its folder is a 409.
folderUid or pathMoves it to another folder in the same storage. A path is created if missing.
metadataReplaces its metadata. Must be a JSON object.
ownerUidGives 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

Statuserror.codeWhen
400noneA name with a /, a reserved name (. or ..), no file in the request, metadata that is not an object, a move into another storage.
402SUBSCRIPTION_LIMIT_REACHEDThe upload would take you past your total storage.
403nonePolicy does not grant this action (or isOwner refused another user's file).
403STORAGE_SHARED_READ_ONLYThe app's owner shared this with you: you can open it, not change it.
404STORAGE_NOT_FOUNDNo storage with that uid.
404FOLDER_NOT_FOUNDNo folder with that uid or path that you can see.
404noneNo file with that uid that you can see, including another organization's.
400UPLOAD_NOT_CONFIRMABLEA confirm without the target's confirmToken, from someone other than who requested it, or after it expired.
409noneA folder name already used in that place, or a direct upload confirmed twice or never uploaded.
413FILE_TOO_LARGEA file over the per-file limit.
415MIME_NOT_ALLOWEDA file type the storage does not accept.

Next

On this page