How to organize recordings with folders and tags
Folders and tags keep a growing number of recordings easy to find, and folders decide who gets to see what. This guide shows how to find folders, file recordings, share folders with users and teams, and label recordings and appointments with tags, with a complete script at the end.
Every example works as it stands once SALLY_TOKEN and DIRECTORY_ID are set, see the Quickstart. Pick your language once, every example on the API pages follows.
Quick navigation
- Find folders
- Add and remove recordings
- List the recordings of a folder
- Rename or move a folder
- Share folders
- Tags
- Complete script
- Endpoints at a glance
1. Find folders
Folders form a tree: a folder can have a parent (parentFolderId), and a recording can be added to a folder and removed again. GET/v1.0/directories/{directoryId}/folders returns your personal, shared and team folders in one paged list.
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | Case-insensitive substring match on the folder name. | |
parentRecordingFolderId | string | Only the direct children of this folder. Leave it out for all folders. | |
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
This request looks for folders whose name contains "sales":
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders?search=sales" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(`https://api.sally.io/v1.0/directories/${directoryId}/folders`);
url.searchParams.set('search', 'sales');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const {items} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/folders",
params={"search": "sales"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
data = response.json()
{
"page": 1,
"pageSize": 25,
"total": 1,
"hasMore": false,
"items": [
{
"folderId": "f0186448-90aa-4df8-a13d-17a832c35c28",
"name": "Sales calls",
"description": "Customer calls of the sales team",
"ownerUserId": "eece96ba-87a6-4f16-a07d-a7720d671312",
"parentFolderId": null,
"isOwner": true,
"isFavorite": false,
"accessLevel": "readWrite",
"recordingsCount": 3,
"createdOn": "2026-09-24T09:30:00Z",
"modifiedOn": "2026-09-24T09:30:00Z"
}
]
}
| Field | Type | Meaning |
|---|---|---|
folderId | string | Id of the folder. |
name | string | Folder display name. |
description | string or null | Folder description, null if unset. |
ownerUserId | string | Id of the user who owns the folder. |
parentFolderId | string or null | Id of the parent folder, null for a top-level folder. |
isOwner | boolean | true if you own the folder. |
isFavorite | boolean | true if you marked the folder as favorite. |
accessLevel | string | Your effective access: none, read or readWrite. |
recordingsCount | number | Number of recordings in the folder. |
createdOn | string | Creation timestamp (ISO 8601). |
modifiedOn | string | Last modification timestamp (ISO 8601). |
POST/v1.0/directories/{directoryId}/folders/search takes the same filters (search, parentRecordingFolderId, page, pageSize) in the request body, see Search endpoints.
2. Add and remove recordings
PUT/v1.0/directories/{directoryId}/folders/{folderId}/recordings/{recordingId} puts a recording into a folder, DELETE/v1.0/directories/{directoryId}/folders/{folderId}/recordings/{recordingId} takes it out again. Both need read-write access to the folder and answer 204 without a body. Adding is idempotent, so calling it twice does no harm. If the recording does not exist in the company account, you get 404.
- cURL
- JavaScript
- Python
# Add
curl -X PUT "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders/$FOLDER_ID/recordings/$RECORDING_ID" \
-H "Authorization: Bearer $SALLY_TOKEN"
# Remove
curl -X DELETE "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders/$FOLDER_ID/recordings/$RECORDING_ID" \
-H "Authorization: Bearer $SALLY_TOKEN"
const path = `https://api.sally.io/v1.0/directories/${directoryId}/folders/${folderId}/recordings/${recordingId}`;
const headers = {Authorization: `Bearer ${process.env.SALLY_TOKEN}`};
await fetch(path, {method: 'PUT', headers}); // add, 204
await fetch(path, {method: 'DELETE', headers}); // remove, 204
import os
import requests
url = f"https://api.sally.io/v1.0/directories/{directory_id}/folders/{folder_id}/recordings/{recording_id}"
headers = {"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"}
requests.put(url, headers=headers).raise_for_status() # add, 204
requests.delete(url, headers=headers).raise_for_status() # remove, 204
3. List the recordings of a folder
GET/v1.0/directories/{directoryId}/folders/{folderId}/recordings returns the recordings of a folder, newest first. With includeSubfolders=true it also includes the recordings of all accessible subfolders, without duplicates.
| Parameter | Type | Default | Description |
|---|---|---|---|
includeSubfolders | boolean | false | true includes the recordings of all nested subfolders. |
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders/$FOLDER_ID/recordings?includeSubfolders=true&pageSize=100" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/folders/${folderId}/recordings`,
);
url.searchParams.set('includeSubfolders', 'true');
url.searchParams.set('pageSize', '100');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const {items, hasMore} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/folders/{folder_id}/recordings",
params={"includeSubfolders": "true", "pageSize": 100},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
data = response.json()
{
"page": 1,
"pageSize": 100,
"total": 3,
"hasMore": false,
"items": [
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"durationInSeconds": 1843,
"isManualUpload": false,
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"createdOn": "2026-09-24T09:30:00Z"
}
]
}
| Field | Type | Meaning |
|---|---|---|
recordingId | string | Id of the recording. Fetch its details with GET …/recordings/{recordingId}. |
durationInSeconds | number or null | Duration in seconds, null if unknown. |
isManualUpload | boolean | true if the recording was uploaded manually (no meeting bot). |
appointmentId | string or null | Your own appointment linked to the recording, null if none. |
createdOn | string | Creation timestamp (ISO 8601, UTC). |
4. Rename or move a folder
PATCH/v1.0/directories/{directoryId}/folders/{folderId} changes the name, the description or the place of a folder in the tree. Only the fields you send are changed, and the shares stay untouched. You need read-write access to the folder.
| Field | Type | Description |
|---|---|---|
name | string | New folder name. |
description | string or null | New description. null clears it. |
parentFolderId | string or null | Moves the folder under this parent. null makes it a top-level folder. |
- cURL
- JavaScript
- Python
curl -X PATCH "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders/$FOLDER_ID" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"parentFolderId": "bf7cd0f0-3c69-4690-a956-9c30b123ab30"}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/folders/${folderId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({parentFolderId: 'bf7cd0f0-3c69-4690-a956-9c30b123ab30'}),
},
);
const folder = await response.json();
import os
import requests
response = requests.patch(
f"https://api.sally.io/v1.0/directories/{directory_id}/folders/{folder_id}",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={"parentFolderId": "bf7cd0f0-3c69-4690-a956-9c30b123ab30"},
)
folder = response.json()
The answer is the updated folder, in the same shape as the entries in Find folders.
5. Share folders
Sharing happens on the folder, with individual users or with whole teams. That is the practical way to open a set of meetings to a department without touching every recording. PUT/v1.0/directories/{directoryId}/folders/{folderId}/shares sets the shares, and it replaces the complete list. Only the folder owner may change shares.
| Field | Type | Description |
|---|---|---|
userShares | array | Required. Direct user shares, each {"userId": "…", "accessLevel": "…"}. |
teamShares | array | Required. Team shares, each {"teamId": "…", "accessLevel": "…"}. |
accessLevel is none, read or readWrite. To add one share without losing the others, read the current shares with GET/v1.0/directories/{directoryId}/folders/{folderId}/shares first, add yours and send both lists back.
This request gives a team read access and one user read-write access:
- cURL
- JavaScript
- Python
curl -X PUT "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/folders/$FOLDER_ID/shares" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userShares": [
{"userId": "11f967df-7dd1-4719-a9aa-46530940c443", "accessLevel": "readWrite"}
],
"teamShares": [
{"teamId": "3c35d421-a578-4ce7-aebb-c5add21907bd", "accessLevel": "read"}
]
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/folders/${folderId}/shares`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
userShares: [{userId: '11f967df-7dd1-4719-a9aa-46530940c443', accessLevel: 'readWrite'}],
teamShares: [{teamId: '3c35d421-a578-4ce7-aebb-c5add21907bd', accessLevel: 'read'}],
}),
},
);
const shares = await response.json();
import os
import requests
response = requests.put(
f"https://api.sally.io/v1.0/directories/{directory_id}/folders/{folder_id}/shares",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"userShares": [{"userId": "11f967df-7dd1-4719-a9aa-46530940c443", "accessLevel": "readWrite"}],
"teamShares": [{"teamId": "3c35d421-a578-4ce7-aebb-c5add21907bd", "accessLevel": "read"}],
},
)
shares = response.json()
The answer holds the resulting shares, plus the shares the folder inherits from its parents:
{
"userShares": [
{"userId": "11f967df-7dd1-4719-a9aa-46530940c443", "accessLevel": "readWrite"}
],
"teamShares": [
{"teamId": "3c35d421-a578-4ce7-aebb-c5add21907bd", "accessLevel": "read"}
],
"inheritedShares": [
{
"userId": "11f967df-7dd1-4719-a9aa-46530940c443",
"accessLevel": "read",
"inheritedFromFolderId": "a1ae243c-6bc2-4da4-a5d7-d70c50ba1acc"
}
]
}
inheritedShares come from parent folders and are read-only, you cannot set them here. Shares carry only ids. Resolve users with the membership endpoints and teams with the team endpoints.
6. Tags
Tags are labels on appointments and recordings. They are created once per company account in the tag catalog, carry a color from a fixed palette (for example blue or green) and are then assigned by id. Admins and owners manage the catalog.
GET/v1.0/directories/{directoryId}/tags returns all active tags of the catalog, most used first:
{
"items": [
{"tagId": "18063a7e-a82b-4972-a850-f866b8b4a046", "name": "Customer", "colorCode": "indigo"}
]
}
POST/v1.0/directories/{directoryId}/tags adds a tag to the catalog. Names are deduplicated case-insensitively.
| Field | Type | Description |
|---|---|---|
name | string | Required. Display name, at most 100 characters. |
colorCode | string or null | A color name from the palette, not a hex value: indigo, purple, fuchsia, pink, rose, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, slate, gray, zinc, stone or neutral. An unknown key renders in the neutral default color. GET/v1.0/directories/{directoryId}/tags/colors lists the palette. |
PUT/v1.0/directories/{directoryId}/recordings/{recordingId}/tags and PUT/v1.0/directories/{directoryId}/appointments/{appointmentId}/tags assign tags with a body of tagIds:
- cURL
- JavaScript
- Python
# Create a tag (admin or owner)
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tags" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Customer", "colorCode": "indigo"}'
# Assign it to a recording
curl -X PUT "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/tags" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tagIds": ["18063a7e-a82b-4972-a850-f866b8b4a046"]}'
const base = `https://api.sally.io/v1.0/directories/${directoryId}`;
const headers = {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
};
// Create a tag (admin or owner)
const tag = await fetch(`${base}/tags`, {
method: 'POST',
headers,
body: JSON.stringify({name: 'Customer', colorCode: 'indigo'}),
}).then((r) => r.json());
// Assign it to a recording
const tags = await fetch(`${base}/recordings/${recordingId}/tags`, {
method: 'PUT',
headers,
body: JSON.stringify({tagIds: [tag.tagId]}),
}).then((r) => r.json());
import os
import requests
base = f"https://api.sally.io/v1.0/directories/{directory_id}"
headers = {"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"}
# Create a tag (admin or owner)
tag = requests.post(
f"{base}/tags", headers=headers, json={"name": "Customer", "colorCode": "indigo"}
).json()
# Assign it to a recording
tags = requests.put(
f"{base}/recordings/{recording_id}/tags", headers=headers, json={"tagIds": [tag["tagId"]]}
).json()
The assignment answers with the tags as an array:
[
{"tagId": "18063a7e-a82b-4972-a850-f866b8b4a046", "name": "Customer", "colorCode": "indigo"}
]
Assigning replaces the complete set of tags and only accepts ids of existing tags. Create a missing tag first, then assign it. A recording or an appointment can carry up to 20 tags of its own.
A recording can also inherit tags from its linked appointments and their recurring series. Those stay dynamic, the call on the recording only sets its direct tags. Series tags live on the recurring master and are not set on a single appointment. The tags field of a recording lists its effective tags, own and inherited, so build the set you assign from your own data rather than copying that field.
7. Complete script
This script files a recording into a folder by name and gives it a set of tags by name. It finds the folder with a search, looks up each tag in the catalog case-insensitively, creates missing tags (this part needs an admin or owner token) and then assigns the whole set. Set RECORDING_ID, FOLDER_NAME and TAG_NAMES (comma-separated) before you run it.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs SALLY_TOKEN, DIRECTORY_ID, RECORDING_ID, FOLDER_NAME, TAG_NAMES, plus jq.
set -euo pipefail
BASE="https://api.sally.io/v1.0/directories/$DIRECTORY_ID"
AUTH="Authorization: Bearer $SALLY_TOKEN"
# 1. Find the folder by exact name (case-insensitive) and file the recording.
folder_id=$(curl -s -G "$BASE/folders" -H "$AUTH" --data-urlencode "search=$FOLDER_NAME" \
| jq -r --arg n "$FOLDER_NAME" '.items[] | select((.name | ascii_downcase) == ($n | ascii_downcase)) | .folderId' | head -n1)
[ -n "$folder_id" ] || { echo "Folder not found: $FOLDER_NAME" >&2; exit 1; }
curl -s -X PUT "$BASE/folders/$folder_id/recordings/$RECORDING_ID" -H "$AUTH"
# 2. Resolve tag names to ids, creating missing tags.
catalog=$(curl -s "$BASE/tags" -H "$AUTH")
tag_ids=()
IFS=',' read -ra names <<< "$TAG_NAMES"
for name in "${names[@]}"; do
id=$(echo "$catalog" | jq -r --arg n "$name" '.items[] | select((.name | ascii_downcase) == ($n | ascii_downcase)) | .tagId' | head -n1)
if [ -z "$id" ]; then
id=$(curl -s -X POST "$BASE/tags" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n --arg n "$name" '{name: $n}')" | jq -r '.tagId')
fi
tag_ids+=("$id")
done
# 3. Assign the complete set.
curl -s -X PUT "$BASE/recordings/$RECORDING_ID/tags" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(printf '%s\n' "${tag_ids[@]}" | jq -R . | jq -s '{tagIds: .}')" | jq .
// organize.mjs, needs SALLY_TOKEN, DIRECTORY_ID, RECORDING_ID, FOLDER_NAME, TAG_NAMES.
const BASE = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}`;
const {RECORDING_ID, FOLDER_NAME, TAG_NAMES} = process.env;
async function sally(method, path, body) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
...(body ? {'Content-Type': 'application/json'} : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
return response.status === 204 ? null : response.json();
}
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
// 1. Find the folder and file the recording.
const folders = await sally('GET', `/folders?search=${encodeURIComponent(FOLDER_NAME)}&pageSize=100`);
const folder = folders.items.find((f) => same(f.name, FOLDER_NAME));
if (!folder) throw new Error(`Folder not found: ${FOLDER_NAME}`);
await sally('PUT', `/folders/${folder.folderId}/recordings/${RECORDING_ID}`);
// 2. Resolve tag names to ids, creating missing tags.
const catalog = (await sally('GET', '/tags')).items;
const tagIds = [];
for (const name of TAG_NAMES.split(',').map((n) => n.trim()).filter(Boolean)) {
const tag = catalog.find((t) => same(t.name, name)) ?? (await sally('POST', '/tags', {name}));
tagIds.push(tag.tagId);
}
// 3. Assign the complete set.
const tags = await sally('PUT', `/recordings/${RECORDING_ID}/tags`, {tagIds});
console.log(`Filed into "${folder.name}", tags:`, tags.map((t) => t.name).join(', '));
# organize.py, needs SALLY_TOKEN, DIRECTORY_ID, RECORDING_ID, FOLDER_NAME, TAG_NAMES.
import os
import requests
BASE = f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}"
recording_id = os.environ["RECORDING_ID"]
folder_name = os.environ["FOLDER_NAME"]
tag_names = [n.strip() for n in os.environ["TAG_NAMES"].split(",") if n.strip()]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SALLY_TOKEN']}"
def sally(method, path, body=None, params=None):
response = session.request(method, f"{BASE}{path}", json=body, params=params)
response.raise_for_status()
return None if response.status_code == 204 else response.json()
# 1. Find the folder and file the recording.
folders = sally("GET", "/folders", params={"search": folder_name, "pageSize": 100})["items"]
folder = next((f for f in folders if f["name"].lower() == folder_name.lower()), None)
if folder is None:
raise SystemExit(f"Folder not found: {folder_name}")
sally("PUT", f"/folders/{folder['folderId']}/recordings/{recording_id}")
# 2. Resolve tag names to ids, creating missing tags.
catalog = sally("GET", "/tags")["items"]
tag_ids = []
for name in tag_names:
tag = next((t for t in catalog if t["name"].lower() == name.lower()), None)
if tag is None:
tag = sally("POST", "/tags", {"name": name})
tag_ids.append(tag["tagId"])
# 3. Assign the complete set.
tags = sally("PUT", f"/recordings/{recording_id}/tags", {"tagIds": tag_ids})
print(f"Filed into \"{folder['name']}\", tags:", ", ".join(t["name"] for t in tags))
8. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
Folders
- GET
…/foldersLists the recording folders the caller can access (paginated, optionally filtered)Try it - POST
…/folders/searchSearches recording folders (parameters in the body)Try it - GET
…/folders/{folderId}Returns a single recording folderTry it - PATCH
…/folders/{folderId}Updates a recording folder (name, description, parent)Try it - GET
…/folders/{folderId}/recordingsLists the recordings in a folder (paginated)Try it - PUT
…/folders/{folderId}/recordings/{recordingId}Adds a recording to a folderTry it - DELETE
…/folders/{folderId}/recordings/{recordingId}Removes a recording from a folderTry it - GET
…/folders/{folderId}/sharesReturns the shares of a recording folderTry it - PUT
…/folders/{folderId}/sharesSets the shares of a recording folder (replace-set)Try it
Tags
- GET
…/tagsLists the tag catalog of a directory (most-used first)Try it - GET
…/tags/colorsLists the available tag coloursTry it - POST
…/tagsCreates a catalog tag with an optional colour (admin/owner only)Try it - PATCH
…/tags/{tagId}Renames a catalog tag and/or changes its colour (admin/owner only)Try it - DELETE
…/tags/{tagId}Deletes a catalog tag (admin/owner only)Try it - PUT
…/recordings/{recordingId}/tagsReplaces the direct tags of a recording (assign/unassign)Try it - PUT
…/appointments/{appointmentId}/tagsReplaces the tags of an appointment (assign/unassign)Try it