Appearance
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:
| Who | Access |
|---|---|
The owner (uid) | Read and manage, while still a full member of the workspace |
| Workspace admins and owners | Read and manage every scoped file of the workspace |
A match in readers | Read only |
readers accepts any combination of:
| Format | Grants 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 iscid; the two refer to the same value. The id form offiles.list,files.my_filesandfiles.get_file_propertiesreturn raw stored documents, so they showcid(andreaders) 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:
- The file has
isPublic: true. Anyone can read it, without a session. - No session (
/public/*routes): the file is the logo of an active public form. - You uploaded it.
- It is your profile picture.
- It is the cover image of a workflow you can see.
- It is attached to a feed post or a feed comment you can see.
- It is attached to a message in a discussion you are in.
- 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 field | Required | Description |
|---|---|---|
file | yes | The file |
isPublic | no | "true" makes the file readable by anyone, without a session |
workspaceId | no | Upload 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[]
| Argument | Returns |
|---|---|
null or omitted | All your own files |
fileId | That 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? }
| Field | Description |
|---|---|
_id | The file |
name | New file name |
profilepic | true 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
| Argument | Description |
|---|---|
tags | Up to 50 unique tags per call |
workspaceId | For 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
| Route | Auth | Returns |
|---|---|---|
GET /file/:id | session | The file contents. Images, videos and PDFs are served inline, everything else as an attachment. HTML is served as plain text. |
GET /image/:size/:id | session | A 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/:id | none | Like /file/:id, for public files only |
GET /public/image/:size/:id | none | Like /image/:size/:id, for public files only |
POST /files | session | A 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 ownUpload 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: theworkspaceIdform field. It cannot be combined withisPublic=true. Uploads made with a public form'sformkeyignore 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? }
| Field | Description |
|---|---|
workspaceId | The workspace. On an already scoped file it must equal the file's current workspace, which never changes. |
readers | Replaces 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? }
| Field | Type | Default | Description |
|---|---|---|---|
workspaceId | string | required | The workspace whose scoped files to list |
search | string | 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 |
skip | integer | 0 | |
limit | integer (1–100) | 100 | |
sortBy | 'name' | 'created' | 'name' | |
reverse | boolean | false | Sort descending |
scopedOnly | boolean | false | Leave 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:
| Endpoint | On a scoped file |
|---|---|
files.get_file_properties, downloads, zip | Owner (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.* broadcast | files.update { file_id } to the file's own workspace, without tags |
files.set_file_metadata | Owner only, while still a member |
files.remove_files | Owner 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/* routes | Never 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/uploadScope 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
| Operation | Legacy file | Scoped file |
|---|---|---|
| Upload | Any authenticated user | Full members of workspaceId |
| Read (properties, download, zip) | Reverse reference or isPublic | Owner (while member), admins, readers |
Rename (set_file_properties), tag | Anyone who can read it, except guests of the session (rename) or given (tag) workspace | Owner (while member), admins |
| Remove | Owner | Owner (while member) |
files.scope.set | Owner, to scope it (irreversible) | Owner (while member), admins |
files.scope.get | Anyone who can read it | Anyone who can read it |
files.owner.set | Not possible | Admins |
Listed by files.my_files / files.list id form | Owner | Owner (while member) |
Listed by files.list options form | Owner, under every workspace | Owner (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
| Call | Error |
|---|---|
files.scope.set with a different workspaceId than the file's | 191 "A file's workspace cannot be changed" |
files.scope.set on someone else's legacy file | 403 "Only the file owner can scope a file" |
files.scope.set on a public legacy file | 191 "Cannot scope a public file; clear isPublic first" |
POST /upload with isPublic=true and workspaceId | HTTP 400, 191 "A workspace-scoped file cannot be public." |
POST /upload with a malformed workspaceId | HTTP 400, 191 "Invalid workspaceId." |
Concurrent files.scope.set / files.owner.set on the same file | 127 "File was modified concurrently, try again" |
Invalid readers
| Call | Error |
|---|---|
| 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 in | 403 "Only workspace admins can grant access to teams they are not a member of", with details.denied |
group_ entries or malformed readers | 191 (validation) |
Ownership transfer
| Call | Error |
|---|---|
| Not a workspace admin (including the owner) | 403 "Only workspace admins can transfer files" |
| A legacy file | 191 "Only workspace-scoped files can be transferred" |
| New owner is a guest or not a member | 191 "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
| Signal | Sent by | Payload | To |
|---|---|---|---|
files.update | files.scope.set, files.owner.set | { file_id } | The file's workspace |
files.update | files.tags.* on a scoped file | { file_id } | The file's workspace |
files.update | files.tags.* on a legacy file with workspaceId | { file_id, tags } | workspaceId |
files.remove | files.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
isPublicflag, 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.listand the file section ofv2.search.globalcombine 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.
Related Endpoints
v3.credential.*— The credential vault uses the samereadersmodelv3.discussion.files,v2.activities.files— Files attached to a discussion or activity, with unreadable scoped files left outmessenger.send,v3.feed.post.create,v3.activity.create— Attach files to messages, posts and activitiescompany.update_team_users— Manage team membership (affectsteam_reader access)v2.network.invite.send— Add members to a workspace (affectsnetwork_anduser_reader access)