Skip to content

Files ​

Upload, list, tag, download and remove files, and control who can read them. The per-endpoint reference is generated in Files.

Every file is in one of two modes. Legacy files are the original model: anyone who can see something that points at the file can read it. Workspace-scoped files belong to one workspace and are readable only through an explicit access list. Most endpoints work on both modes, but the access rules differ, so each mode has its own section below.

Understanding File Modes ​

Legacy (unscoped) files ​

Every file uploaded without a workspaceId, which includes every file that existed before workspace scoping. A legacy file has no workspace. Read access is decided by reverse reference: you may read the file when you uploaded it or when something you can already see points at it (see Legacy access).

Workspace-scoped files ​

A file uploaded with a workspaceId, or scoped later with files.scope.set. Access is decided by the file's own ACL. Attachments grant nothing:

WhoAccess
The owner (uid)Read and manage, while still a full member of the workspace
Workspace admins and ownersRead and manage every scoped file of the workspace
A match in readersRead only

readers accepts any combination of:

FormatGrants read access to
network_<workspaceId>All full members of the workspace
team_<teamId>Members of a team in the workspace
user_<userId>A specific member of the workspace

A scoped file with no readers is visible to its owner and the workspace admins only. Workspace guests have no access to scoped files: network_ never matches them, user_ cannot name them, a team_ grant skips them, and they cannot own one. A scoped file is never public.

Naming note: at the API boundary the workspace scope is named workspaceId. Internally the stored document field is cid; the two refer to the same value. The id form of files.list, files.my_files and files.get_file_properties return raw stored documents, so they show cid (and readers) instead.

Scoping is strict and one-way. Once a file has a workspace, attaching it to an activity, discussion message or feed post no longer grants access to it. For people who can see the container but are not readers, the attachment is left out of the message, post or activity as if it were not there. The workspace can never be changed or removed, and there is no way back to legacy.


Legacy Files ​

Legacy access ​

files.get_file_properties, the download routes and the zip route return a legacy file when the first matching rule applies:

  1. The file has isPublic: true. Anyone can read it, without a session.
  2. No session (/public/* routes): the file is the logo of an active public form.
  3. You uploaded it.
  4. It is your profile picture.
  5. It is the cover image of a workflow you can see.
  6. It is attached to a feed post or a feed comment you can see.
  7. It is attached to a message in a discussion you are in.
  8. It is attached to an activity you can see.

Otherwise the file is treated as not found. The rules for a legacy file have not changed with workspace scoping.

files.upload ​

Upload a file as base64 or binary content over the websocket API.

Arguments: content: Base64String | Buffer, filename: string

Permissions: Any authenticated user.

Response:

json
{ "_id": "66f0c0ffee0000000000f11e" }

A third workspaceId argument uploads a scoped file instead (see Scoping at upload).


POST /upload ​

Upload a file as multipart/form-data. Authenticate with the hlrkey header, cookie or form field.

Form fieldRequiredDescription
fileyesThe file
isPublicno"true" makes the file readable by anyone, without a session
workspaceIdnoUpload a scoped file instead (see Scoping at upload)

Response: 200 { "_id": "<fileId>" }

An upload over the size limit gets 413. A file type blocked by the education (DigiOne) allow-list gets 415. Uploads authenticated with a public form's formkey header are stored as the form's creator.


files.my_files ​

List your own files.

Arguments: none

Returns a map of every file you uploaded, keyed by fileId, leaving out profile pictures and removed files. Your own scoped files are included only while you are still a full member of their workspace.

Response:

json
{
  "66f0c0ffee0000000000f11e": {
    "_id": "66f0c0ffee0000000000f11e",
    "uid": "66f0c0ffee00000000000b0b",
    "name": "notes.txt",
    "type": "text/plain",
    "length": 1204,
    "created": 1727000000000
  }
}

files.list (id form) ​

The same listing as files.my_files, optionally narrowed to the given ids.

Arguments: fileIds?: null | string | string[]

ArgumentReturns
null or omittedAll your own files
fileIdThat file, if you own it
fileId[]The ones among them you own

Ids you do not own are silently left out. The response is a map keyed by fileId, like files.my_files. Passing an options object instead switches to the paginated listing described in files.list (options form).


files.get_file_properties ​

Get the stored document of a file you can read.

Arguments: fileId: string

Permissions: Legacy access for a legacy file, the ACL for a scoped one.

Response: the file document (_id, uid, name, type, length, tags, dimensions, …).


files.set_file_properties ​

Rename a file or mark it as a profile picture.

Arguments: properties: { _id, name?, profilepic? }

FieldDescription
_idThe file
nameNew file name
profilepictrue hides the file from your file lists

fileSets, typeContent, isVideo, isPicture and isPublic are accepted for backwards compatibility but never written. In particular, a file cannot be made public through this endpoint.

Permissions: Anyone who can read a legacy file, unless they are a guest of their session workspace. On a scoped file, only its owner (while a member) or a workspace admin. Readers cannot rename it.

Response: true


files.set_file_metadata ​

Set the profilepic flag on one of your own files.

Arguments: file: { file_id }, metadata: { profilepic? }

Permissions: The file's owner. Other files are silently left unchanged. On a scoped file the owner must still be a full member of its workspace, otherwise 403 "Permission denied".

Response: empty


files.tags.add / files.tags.remove ​

Add or remove case-sensitive tags on a file. Tags are trimmed, 1–64 characters, and a file holds at most 50.

Arguments: fileId: string, tags: string[], workspaceId?: string

ArgumentDescription
tagsUp to 50 unique tags per call
workspaceIdFor a legacy file: denies guests of this workspace, and broadcasts files.update with the new tags to it

add ignores tags the file already has and fails if the result would exceed 50. remove silently ignores tags the file does not have.

Permissions: Anyone who can read a legacy file. On a scoped file, only its owner (while a member) or a workspace admin.

Response: the file's tags after the change, e.g. ["q3", "finance"]

See files.tags.add for the generated reference.


files.remove_files ​

Remove your own files.

Arguments: fileIds: string[]

Only files you own are removed. Other ids are silently skipped, even for workspace admins. Your own scoped files are removed only while you are still a full member of their workspace. The records are deleted and the contents purged from storage. For education (DigiOne) users the files are only flagged as removed and kept.

Broadcasts files.remove { file_id: string[] } to your session workspace.

Response: true


Downloading ​

RouteAuthReturns
GET /file/:idsessionThe file contents. Images, videos and PDFs are served inline, everything else as an attachment. HTML is served as plain text.
GET /image/:size/:idsessionA resized image. size is one of square25, square50, square75, square100, square200, profile, smallface, bigface, thumb, gallerycover, fullhd, hires, lores, pdf, original. ?format=png returns PNG instead of JPEG.
GET /public/file/:idnoneLike /file/:id, for public files only
GET /public/image/:size/:idnoneLike /image/:size/:id, for public files only
POST /filessessionA zip of { fileIds: string[], archiveName: string }. Fails with 403 if any file is unreadable.

All of them apply the same access check as files.get_file_properties. Scoped files are never served on the /public/* routes.

GET /image/:size/:id sends scoped images with Cache-Control: private, no-cache and an ETag, so the browser revalidates them on every use: a matching If-None-Match gets 304 while you can still read the file, and 404 once you cannot. Other images may be cached for a week.


Legacy examples ​

Upload and list your files ​

javascript
const { _id } = await client.request('files.upload', [base64Content, 'notes.txt']);

const all = await client.request('files.my_files', []);
const some = await client.request('files.list', [[_id, otherFileId]]);   // only the ones you own

Upload over HTTP ​

bash
curl -sS -H "hlrkey: $SESSION_KEY" -F file=@notes.txt https://api.hailer.com/upload
# 200 {"_id":"66f0c0ffee0000000000f11e"}

Tag, rename and remove ​

javascript
await client.request('files.tags.add', [fileId, ['q3', 'finance'], workspaceId]);   // -> ['q3', 'finance']
await client.request('files.tags.remove', [fileId, ['q3']]);                      // -> ['finance']
await client.request('files.set_file_properties', [{ _id: fileId, name: 'notes-final.txt' }]);
await client.request('files.remove_files', [[fileId]]);

Workspace-scoped Files ​

Scoping at upload ​

Pass a workspaceId to upload a scoped file. The file starts with no readers, so only you and the workspace admins can read it until you grant readers with files.scope.set.

  • files.upload: third argument, content, filename, workspaceId.
  • POST /upload: the workspaceId form field. It cannot be combined with isPublic=true. Uploads made with a public form's formkey ignore it and always store a legacy file.

Permissions: Full members of workspaceId. Guests and non-members are denied.


files.scope.set ​

Scope a file to a workspace and/or replace its readers.

Arguments: fileId: string, scope: { workspaceId, readers? }

FieldDescription
workspaceIdThe workspace. On an already scoped file it must equal the file's current workspace, which never changes.
readersReplaces the stored list wholesale. Omit it to keep the list, send [] to revoke everyone. Max 100 entries.

Permissions:

  • Legacy file: only its owner, who must be a full member of workspaceId. A public file must be made private first. A workspace admin cannot scope someone else's legacy file.
  • Scoped file: its owner (while a member, whatever their role) or a workspace admin or owner.
  • Never guests of workspaceId.

Reader rules: every entry must belong to workspaceId. network_ must be that workspace, team_ a team in it, and user_ a full member (not a guest). A non-admin may only newly add teams they belong to. Entries that are already stored are kept without re-checking, so a team stays valid after the person who granted it leaves the team.

Broadcasts files.update { file_id } to the workspace.

Response:

json
{
  "_id": "66f0c0ffee0000000000f11e",
  "uid": "66f0c0ffee00000000000b0b",
  "workspaceId": "66f0c0ffee00000000000aaa",
  "readers": ["team_66f0c0ffee0000000000f00d"]
}

files.scope.get ​

Get the owner, workspace and readers of a file you can read.

Arguments: fileId: string

workspaceId and readers are absent for a legacy file, so this also works as an "is this file scoped?" probe. Guests get NotFound for the scoped files of their workspace, like for any file they cannot read.

Response: { _id, uid, workspaceId?, readers? }, same shape as files.scope.set.


files.list (options form) ​

Search and page through the files you can read in a workspace. This is how readers discover files shared with them.

Arguments: options: { workspaceId, search?, matchType?, skip?, limit?, sortBy?, reverse?, scopedOnly? }

FieldTypeDefaultDescription
workspaceIdstringrequiredThe workspace whose scoped files to list
searchstring | string[] (1–20)–Each keyword is a case-insensitive substring of the name or of any tag. Regex characters match literally.
matchType'all' | 'any''all'Whether every keyword must match, or at least one
skipinteger0
limitinteger (1–100)100
sortBy'name' | 'created''name'
reversebooleanfalseSort descending
scopedOnlybooleanfalseLeave out your legacy files

Returns your own legacy files plus the scoped files of workspaceId you can read: your own, the ones whose readers match you, or all of them if you are a workspace admin or owner.

Legacy files have no workspace, so yours are listed under every workspaceId, including one where you are a guest or not a member at all. Guests and non-members get only their own legacy files, without an error. With scopedOnly: true, legacy files are left out and guests get PermissionDenied. Apps always run with scopedOnly: true (see Apps).

Items carry workspaceId, which is absent on legacy files, and never include readers or hash.

Response:

json
{
  "files": [
    {
      "_id": "66f0c0ffee0000000000f11e",
      "uid": "66f0c0ffee00000000000b0b",
      "name": "budget.xlsx",
      "tags": ["q3", "finance"],
      "workspaceId": "66f0c0ffee00000000000aaa",
      "created": 1727000000000
    },
    {
      "_id": "66f0c0ffee0000000000f12a",
      "uid": "66f0c0ffee0000000000c1e0",
      "name": "finance-notes.pdf",
      "created": 1726000000000
    }
  ],
  "hasMore": false,
  "total": 2
}

files.owner.set ​

Transfer a scoped file to another member of its workspace.

Arguments: fileId: string, owner: { uid }

Permissions: Workspace admins and owners only. The file's owner cannot transfer it themselves, and legacy files cannot be transferred. The new owner must be a full member of the file's workspace, not a guest. Setting the current owner is a no-op.

files.my_files, the id form of files.list and files.remove_files only cover your own files, so the file moves to the new owner's lists and delete rights. The previous owner loses access unless a readers entry matches them.

Broadcasts files.update { file_id } to the workspace.

Response: true


Shared endpoints on scoped files ​

The legacy endpoints also work on scoped files, with stricter rules:

EndpointOn a scoped file
files.get_file_properties, downloads, zipOwner (while a member), workspace admins, readers
files.set_file_properties, files.tags.*Owner (while a member) or workspace admins. Readers cannot rename or tag.
files.tags.* broadcastfiles.update { file_id } to the file's own workspace, without tags
files.set_file_metadataOwner only, while still a member
files.remove_filesOwner only, while still a member. Admins cannot delete.
files.my_files, files.list (id form)Listed for the owner only while they are a member
/public/* routesNever served

When the owner is removed from the workspace or made a guest, they lose read, manage and delete rights, and the file drops out of their lists. A workspace admin can hand the file to someone else with files.owner.set.

Apps ​

Apps reach files through the app SDK's hailer.file module, which the host maps onto these endpoints. Apps only ever see scoped files of the workspace they run in. file.upload(..., { scoped: true }) scopes to the app's workspace, and file.list always runs files.list with scopedOnly: true, which an app cannot turn off.


Scoped examples ​

Upload a scoped file and share it with a team ​

javascript
const { _id: fileId } = await client.request('files.upload', [base64Content, 'budget.xlsx', workspaceId]);

await client.request('files.scope.set', [fileId, { workspaceId, readers: [`team_${financeTeamId}`] }]);
bash
curl -sS -H "hlrkey: $SESSION_KEY" -F file=@budget.xlsx -F workspaceId=$WORKSPACE_ID https://api.hailer.com/upload

Scope an existing legacy file ​

javascript
// Owner only. Irreversible.
await client.request('files.scope.set', [legacyFileId, { workspaceId, readers: [`network_${workspaceId}`] }]);

Change readers ​

javascript
// readers is replaced wholesale, so always send the full list
await client.request('files.scope.set', [fileId, { workspaceId, readers: [`team_${financeTeamId}`, `user_${deeId}`] }]);

// Revoke everyone except the owner and the admins
await client.request('files.scope.set', [fileId, { workspaceId, readers: [] }]);

Find files shared with you ​

javascript
// Newest finance files: your legacy files and the scoped files you can read
await client.request('files.list', [{ workspaceId, search: 'finance', sortBy: 'created', reverse: true, limit: 20 }]);

// Both keywords must match (in the name or any tag)
await client.request('files.list', [{ workspaceId, search: ['q3', 'finance'] }]);

// Either keyword, scoped files only
await client.request('files.list', [{ workspaceId, search: ['invoice', 'receipt'], matchType: 'any', scopedOnly: true }]);

Full implementation class ​

javascript
class ScopedFilesClient {
    constructor(client, workspaceId) {
        this.client = client;
        this.workspaceId = workspaceId;
    }

    async upload(base64Content, filename) {
        const { _id } = await this.client.request('files.upload', [base64Content, filename, this.workspaceId]);
        return _id;
    }

    async share(fileId, readers) {
        // readers replaces the stored list wholesale
        return this.client.request('files.scope.set', [fileId, { workspaceId: this.workspaceId, readers }]);
    }

    async scope(fileId) {
        return this.client.request('files.scope.get', [fileId]);
    }

    async search(options = {}) {
        return this.client.request('files.list', [{ ...options, workspaceId: this.workspaceId, scopedOnly: true }]);
    }

    async transfer(fileId, uid) {
        // workspace admins only
        return this.client.request('files.owner.set', [fileId, { uid }]);
    }

    async remove(fileIds) {
        // owner only, while still a member
        return this.client.request('files.remove_files', [fileIds]);
    }
}

Permission Summary ​

OperationLegacy fileScoped file
UploadAny authenticated userFull members of workspaceId
Read (properties, download, zip)Reverse reference or isPublicOwner (while member), admins, readers
Rename (set_file_properties), tagAnyone who can read it, except guests of the session (rename) or given (tag) workspaceOwner (while member), admins
RemoveOwnerOwner (while member)
files.scope.setOwner, to scope it (irreversible)Owner (while member), admins
files.scope.getAnyone who can read itAnyone who can read it
files.owner.setNot possibleAdmins
Listed by files.my_files / files.list id formOwnerOwner (while member)
Listed by files.list options formOwner, under every workspaceOwner (while member), admins, readers

Error Responses ​

Errors use the standard Hailer error object { msg, code, details? }.

Not found ​

json
{ "msg": "File not found", "code": 404 }

The file does not exist, or you cannot read it. Unreadable files are reported as not found so their existence is not revealed. files.get_file_properties reports { "msg": "Unable to get file properties.", "code": 127 } instead. Downloads return HTTP 404.

Guests ​

json
{ "msg": "Guests cannot manage file scopes", "code": 403 }

files.scope.set or files.list with scopedOnly: true by a guest of the workspace. A scoped upload by a guest gets "Guests cannot scope files.".

Not a member ​

json
{ "msg": "Not a member of the workspace.", "code": 403 }

Scoped upload to a workspace you are not in. Over HTTP this is status 403.

Access denied ​

json
{ "msg": "Access denied", "code": 403 }

files.scope.set on a scoped file by someone who is neither its owner (while a member) nor a workspace admin. files.set_file_properties and files.tags.* return "Permission denied" in the same case, and so does files.set_file_metadata for an owner who is no longer a member.

Invalid scope changes ​

CallError
files.scope.set with a different workspaceId than the file's191 "A file's workspace cannot be changed"
files.scope.set on someone else's legacy file403 "Only the file owner can scope a file"
files.scope.set on a public legacy file191 "Cannot scope a public file; clear isPublic first"
POST /upload with isPublic=true and workspaceIdHTTP 400, 191 "A workspace-scoped file cannot be public."
POST /upload with a malformed workspaceIdHTTP 400, 191 "Invalid workspaceId."
Concurrent files.scope.set / files.owner.set on the same file127 "File was modified concurrently, try again"

Invalid readers ​

CallError
A reader that does not belong to the workspace (a guest, a team or user elsewhere)191 "Readers must belong to the file's workspace". Admins also get details.invalid.
A non-admin newly adding a team they are not in403 "Only workspace admins can grant access to teams they are not a member of", with details.denied
group_ entries or malformed readers191 (validation)

Ownership transfer ​

CallError
Not a workspace admin (including the owner)403 "Only workspace admins can transfer files"
A legacy file191 "Only workspace-scoped files can be transferred"
New owner is a guest or not a member191 "New owner must be a member (not a guest) of the file's workspace"

Tags ​

json
{ "msg": "Tag limit exceeded (max 50 per file).", "code": 127 }

Signals ​

SignalSent byPayloadTo
files.updatefiles.scope.set, files.owner.set{ file_id }The file's workspace
files.updatefiles.tags.* on a scoped file{ file_id }The file's workspace
files.updatefiles.tags.* on a legacy file with workspaceId{ file_id, tags }workspaceId
files.removefiles.remove_files{ file_id: string[] }Your session workspace

Signals about scoped files never carry readers, workspaceId or tags: they reach every member of the workspace, including non-readers. Clients re-read through the gated endpoints.

Security Notes ​

  • Contents are always gated. Every byte path (downloads, images, the zip route) goes through the same access check. A scope always beats a stale isPublic flag, and scoped files are never served without a session.
  • Metadata is gated too. Messages, feed posts and comments, activities, links and search results leave out attachments that are scoped files you cannot read, as if they were not attached. Scoped file names are not in the activity search index. A public form's discussion token (v2.forms.discussion.rendered) carries no workspace standing, so it never shows scoped attachments, only legacy ones.
  • Attaching grants nothing. Anyone can attach a scoped fileId to an activity, message or post. This does not widen access, because the scoped file stays readable only through its ACL.
  • Search is gated. files.list and the file section of v2.search.global combine the search filter with the ACL, so searching by name or tag never reveals files you could not already read.
  • Removal is permanent for regular users: the record is deleted and the contents purged from storage.
  • v3.credential.* — The credential vault uses the same readers model
  • v3.discussion.files, v2.activities.files — Files attached to a discussion or activity, with unreadable scoped files left out
  • messenger.send, v3.feed.post.create, v3.activity.create — Attach files to messages, posts and activities
  • company.update_team_users — Manage team membership (affects team_ reader access)
  • v2.network.invite.send — Add members to a workspace (affects network_ and user_ reader access)

Hailer Developer Documentation · v1.8.0