How to keep Sally tasks in sync with your system
Sally derives tasks from your conversations. This guide shows how to read them, move them into the system your team actually uses and write back when a task is done, with a complete sync 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
- Read tasks
- Search tasks
- Create a task
- Change a task
- Complete or reopen a task
- Done is not canceled
- Complete sync script
- Endpoints at a glance
1. Read tasks
GET/v1.0/directories/{directoryId}/tasks returns the tasks of a company account, paged. It takes these query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
filter | string | Preset that combines scope and state, see below. | |
search | string | Case-insensitive substring match on the task subject. | |
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
fields | string | Comma-separated list of the fields to return, see Field selection. |
The filter preset combines scope and state: my… covers every task you are involved in, as the responsible person or as the owner, all… every task of the company account you may see. The state part is Tasks (any), Open, Overdue, Completed or Forwarded, for example myOpen or allOverdue. Forwarded is the one exception to that scope: it lists only what you handed to someone else, so you are the owner and someone else is responsible. The all… presets are reserved for admins and owners.
| Scope | Any | Open | Overdue | Completed | Forwarded |
|---|---|---|---|---|---|
| Yours (responsible or owner) | myTasks | myOpen | myOverdue | myCompleted | myForwarded |
| All (admin, owner) | allTasks | allOpen | allOverdue | allCompleted | allForwarded |
This request fetches your open tasks:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tasks?filter=myOpen&pageSize=100" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(`https://api.sally.io/v1.0/directories/${directoryId}/tasks`);
url.searchParams.set('filter', 'myOpen');
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}/tasks",
params={"filter": "myOpen", "pageSize": 100},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
data = response.json()
{
"page": 1,
"pageSize": 100,
"total": 137,
"hasMore": true,
"items": [
{
"taskId": "6ceba541-fa71-44c7-a7f7-644d56b1b85d",
"subject": "Quarterly planning",
"description": "Prepare the budget figures for the Q4 review.",
"ownerId": "e51b6a79-43bf-494f-a264-6825f41b0fb5",
"responsibleUserId": "1b84577d-c09e-446b-a5b8-71591dfde929",
"taskCategoryId": "4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90",
"dueDate": "2026-09-30T00:00:00Z",
"isDone": false,
"doneOn": null,
"isCanceled": false,
"canceledOn": null,
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"createdOn": "2026-09-24T09:30:00Z",
"modifiedOn": "2026-09-24T09:30:00Z"
}
]
}
Every task has these fields:
| Field | Type | Meaning |
|---|---|---|
taskId | string | Id of the task. |
subject | string | Task subject. |
description | string or null | Task description, null if unset. |
ownerId | string | Id of the user who owns (created) the task. |
responsibleUserId | string or null | Id of the responsible user, null if unassigned. |
taskCategoryId | string or null | Id of the task category, null if the task has none. See Resolve the task category. |
dueDate | string or null | Due date (ISO 8601), null if no due date is set. |
isDone | boolean | Whether the task is marked as done. |
doneOn | string or null | When the task was completed (ISO 8601), null if not done. |
isCanceled | boolean | Whether the task is canceled. |
canceledOn | string or null | When the task was canceled (ISO 8601), null if not canceled. |
recordingSummaryId | string or null | Summary this task originated from, null if none. |
appointmentId | string or null | Appointment this task is linked to, null if none. |
createdOn | string | Creation timestamp (ISO 8601). |
modifiedOn | string | Last modification timestamp (ISO 8601). |
A task knows where it came from: recordingSummaryId and appointmentId point back to the meeting. That lets you show the meeting context next to the task in your own tool.
ownerId and responsibleUserId are plain ids. Resolve them to names and email addresses with GET /v1.0/directories/{directoryId}/memberships/users/{userId}. The loop from Fetch all pages fetches every page.
Resolve the task category
taskCategoryId is a plain id as well. GET/v1.0/directories/{directoryId}/task-categories returns every category a task can carry: the ones your company account owns plus the global Sally defaults (isGlobal: true), sorted by name. The list is not paged, items holds all of them.
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/task-categories" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/task-categories`,
{headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
const {items} = await response.json();
// Map each id to its category, then look up task.taskCategoryId.
const categories = new Map(items.map((category) => [category.taskCategoryId, category]));
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/task-categories",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
# Map each id to its category, then look up task["taskCategoryId"].
categories = {c["taskCategoryId"]: c for c in response.json()["items"]}
{
"items": [
{
"taskCategoryId": "4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90",
"name": "Customer follow-up",
"description": "Calls, emails and offers a customer is waiting for after a meeting.",
"active": true,
"isGlobal": false
},
{
"taskCategoryId": "9a3e5c71-2b8d-4f60-a1c4-7e9d0b2f5a38",
"name": "Internal",
"description": null,
"active": true,
"isGlobal": true
},
{
"taskCategoryId": "c81f4d26-5e0a-4b93-8d7c-3a6b9e1f0d54",
"name": "Trade fair 2025",
"description": "Preparation and follow-up of the 2025 trade fair.",
"active": false,
"isGlobal": false
}
]
}
| Field | Type | Meaning |
|---|---|---|
taskCategoryId | string | Id of the category, the value a task carries in taskCategoryId. |
name | string | Category name. |
description | string or null | What the category covers. Sally is given this text when it picks a category for a recognized task. null if unset. |
active | boolean | Whether the category is still offered for new tasks. |
isGlobal | boolean | true for a global Sally default, false for a category your company account owns. |
Deactivated categories stay in the list with active: false, so a task that still carries one keeps resolving. They are no longer offered for new tasks, so only offer the active ones when your tool lets people pick a category.
2. Search tasks
POST/v1.0/directories/{directoryId}/tasks/search takes its parameters in the request body. Unlike the search query parameter of the list, it matches subject and description, and it filters by status and responsible person. Results are sorted by due date, earliest first.
| Field | Type | Default | Description |
|---|---|---|---|
search | string | Case-insensitive substring match on subject and description. | |
responsible | string | Id of the responsible user. Your own id is always allowed. Another user's id, or leaving the field out (all responsible users), requires admin or owner. | |
status | string | open (neither done nor canceled), completed, overdue (open and past due) or canceled. Leave it out for any status. | |
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
Your own user id is the userId from GET /v1.0/me. This request searches your overdue tasks that mention an offer:
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tasks/search" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"search\": \"offer\",
\"status\": \"overdue\",
\"responsible\": \"$USER_ID\"
}"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/tasks/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({search: 'offer', status: 'overdue', responsible: userId}),
},
);
const {total, items} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/tasks/search",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={"search": "offer", "status": "overdue", "responsible": user_id},
)
data = response.json()
The response has the same envelope and task fields as the list in Read tasks. The matching rules are the same for every search endpoint, see How matching works.
3. Create a task
POST/v1.0/directories/{directoryId}/tasks creates a task. You become its owner.
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | yes | Task subject, 1 to 1000 characters. |
description | string or null | no | Task description, at most 4000 characters. null for none. |
responsibleUserId | string or null | no | Id of the responsible user. null or left out means unassigned. |
taskCategoryId | string or null | no | Id of the task category from the category list. null or left out means none. An id from another company account is rejected. |
dueDate | string or null | no | Due date as an ISO 8601 string. null for no due date. |
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tasks" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject": "Quarterly planning",
"description": "Prepare the budget figures for the Q4 review.",
"dueDate": "2026-09-30T00:00:00Z"
}'
const response = await fetch(`https://api.sally.io/v1.0/directories/${directoryId}/tasks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subject: 'Quarterly planning',
description: 'Prepare the budget figures for the Q4 review.',
dueDate: '2026-09-30T00:00:00Z',
}),
});
const task = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/tasks",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"subject": "Quarterly planning",
"description": "Prepare the budget figures for the Q4 review.",
"dueDate": "2026-09-30T00:00:00Z",
},
)
task = response.json()
The answer is the new task with all its fields:
{
"taskId": "6ceba541-fa71-44c7-a7f7-644d56b1b85d",
"subject": "Quarterly planning",
"description": "Prepare the budget figures for the Q4 review.",
"ownerId": "e51b6a79-43bf-494f-a264-6825f41b0fb5",
"responsibleUserId": null,
"taskCategoryId": null,
"dueDate": "2026-09-30T00:00:00Z",
"isDone": false,
"doneOn": null,
"isCanceled": false,
"canceledOn": null,
"recordingSummaryId": null,
"appointmentId": null,
"createdOn": "2026-09-24T09:30:00Z",
"modifiedOn": "2026-09-24T09:30:00Z"
}
4. Change a task
PATCH/v1.0/directories/{directoryId}/tasks/{taskId} changes a task with PATCH. Only the fields you send are changed, omitted fields keep their value, and null explicitly clears a field. You need permission to edit the task.
| Field | Type | Description |
|---|---|---|
subject | string | New subject, 1 to 1000 characters. |
description | string or null | New description, at most 4000 characters. null clears it. |
responsibleUserId | string or null | New responsible user. null unassigns the task. |
taskCategoryId | string or null | New task category from the category list. null clears it. An id from another company account is rejected. |
dueDate | string or null | New due date as an ISO 8601 string. null clears it. |
This request assigns the task, sets its category and removes its due date:
- cURL
- JavaScript
- Python
curl -X PATCH "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tasks/$TASK_ID" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"responsibleUserId": "1b84577d-c09e-446b-a5b8-71591dfde929",
"taskCategoryId": "4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90",
"dueDate": null
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/tasks/${taskId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
responsibleUserId: '1b84577d-c09e-446b-a5b8-71591dfde929',
taskCategoryId: '4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90',
dueDate: null,
}),
},
);
const task = await response.json();
import os
import requests
response = requests.patch(
f"https://api.sally.io/v1.0/directories/{directory_id}/tasks/{task_id}",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"responsibleUserId": "1b84577d-c09e-446b-a5b8-71591dfde929",
"taskCategoryId": "4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90",
"dueDate": None,
},
)
task = response.json()
The answer is the updated task, in the same shape as in Create a task.
5. Complete or reopen a task
POST/v1.0/directories/{directoryId}/tasks/{taskId}/done completes or reopens a task with its own call, so a completion is always unambiguous. Send {"isDone": true} to mark the task as done and {"isDone": false} to reopen it.
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/tasks/$TASK_ID/done" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"isDone": true}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/tasks/${taskId}/done`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({isDone: true}),
},
);
const task = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/tasks/{task_id}/done",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={"isDone": True},
)
task = response.json()
The answer is the updated task:
{
"taskId": "6ceba541-fa71-44c7-a7f7-644d56b1b85d",
"subject": "Quarterly planning",
"description": "Prepare the budget figures for the Q4 review.",
"ownerId": "e51b6a79-43bf-494f-a264-6825f41b0fb5",
"responsibleUserId": "1b84577d-c09e-446b-a5b8-71591dfde929",
"taskCategoryId": "4f2c8a1e-7b3d-4e9a-b6c5-2d8f1a7e3c90",
"dueDate": "2026-09-30T00:00:00Z",
"isDone": true,
"doneOn": "2026-09-25T14:05:00Z",
"isCanceled": false,
"canceledOn": null,
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"createdOn": "2026-09-24T09:30:00Z",
"modifiedOn": "2026-09-25T14:05:00Z"
}
To remove a task entirely, use DELETE/v1.0/directories/{directoryId}/tasks/{taskId}. It is a soft delete that answers 204, and only the owner of the task or an admin or owner may do it.
6. Done is not canceled
A task has two separate ways to end, and your system should keep them apart:
isDone | isCanceled | State | Timestamp |
|---|---|---|---|
true | false | Finished | doneOn |
false | true | Dropped | canceledOn |
false | false | Still open |
isDone with doneOn means finished, isCanceled with canceledOn means dropped. A task that is neither is still open. If you map both onto a single "closed" state in your system, you lose that difference.
7. Complete sync script
This script pulls all of your tasks page by page, maps each one to the three states above and hands it to your tool. Then it writes completions from your tool back to Sally. Replace the two functions marked your tool with calls to your own system. On a 429 from the rate limit it waits a little longer each time and repeats the same request.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs SALLY_TOKEN and DIRECTORY_ID, plus jq.
set -euo pipefail
BASE="https://api.sally.io/v1.0/directories/$DIRECTORY_ID"
# 1. Pull all of your tasks and print id, state and subject.
page=1
while : ; do
body=$(curl -s "$BASE/tasks?filter=myTasks&page=$page&pageSize=100" \
-H "Authorization: Bearer $SALLY_TOKEN")
echo "$body" | jq -r '.items[]
| [.taskId, (if .isDone then "done" elif .isCanceled then "canceled" else "open" end), .subject]
| @tsv'
[ "$(echo "$body" | jq '.hasMore')" = "true" ] || break
page=$((page + 1))
done
# 2. Write completions back. COMPLETED_IDS holds the ids your tool reports as done.
for task_id in ${COMPLETED_IDS:-}; do
curl -s -X POST "$BASE/tasks/$task_id/done" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"isDone": true}' > /dev/null
done
// sync-tasks.mjs, needs SALLY_TOKEN and DIRECTORY_ID. Node 18 or newer.
const BASE = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function sally(method, path, body) {
for (let wait = 1000; ; wait = Math.min(wait * 2, 60_000)) {
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.status === 429) {
await sleep(wait);
continue;
}
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
return response.status === 204 ? null : response.json();
}
}
async function* myTasks() {
for (let page = 1; ; page++) {
const body = await sally('GET', `/tasks?filter=myTasks&page=${page}&pageSize=100`);
yield* body.items;
if (!body.hasMore) return;
}
}
const stateOf = (task) => (task.isDone ? 'done' : task.isCanceled ? 'canceled' : 'open');
// Your tool: replace these two functions.
async function upsertInMyTool(record) {
console.log('upsert', record.externalId, record.state, record.title);
}
async function completedInMyTool() {
return []; // ids of Sally tasks your team closed in your tool
}
for await (const task of myTasks()) {
await upsertInMyTool({
externalId: task.taskId,
title: task.subject,
notes: task.description,
due: task.dueDate,
state: stateOf(task),
meeting: task.appointmentId,
});
}
for (const taskId of await completedInMyTool()) {
await sally('POST', `/tasks/${taskId}/done`, {isDone: true});
}
# sync_tasks.py, needs SALLY_TOKEN and DIRECTORY_ID.
import os
import time
import requests
BASE = f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SALLY_TOKEN']}"
def sally(method, path, body=None):
wait = 1
while True:
response = session.request(method, f"{BASE}{path}", json=body)
if response.status_code == 429:
time.sleep(wait)
wait = min(wait * 2, 60)
continue
response.raise_for_status()
return None if response.status_code == 204 else response.json()
def my_tasks():
page = 1
while True:
body = sally("GET", f"/tasks?filter=myTasks&page={page}&pageSize=100")
yield from body["items"]
if not body["hasMore"]:
return
page += 1
def state_of(task):
if task["isDone"]:
return "done"
if task["isCanceled"]:
return "canceled"
return "open"
# Your tool: replace these two functions.
def upsert_in_my_tool(record):
print("upsert", record["external_id"], record["state"], record["title"])
def completed_in_my_tool():
return [] # ids of Sally tasks your team closed in your tool
for task in my_tasks():
upsert_in_my_tool({
"external_id": task["taskId"],
"title": task["subject"],
"notes": task["description"],
"due": task["dueDate"],
"state": state_of(task),
"meeting": task["appointmentId"],
})
for task_id in completed_in_my_tool():
sally("POST", f"/tasks/{task_id}/done", {"isDone": True})
Store the Sally taskId in your tool as the external id. Then each run updates the same record instead of creating a duplicate, and you know which id to send back when a task is closed on your side.
8. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
- GET
…/tasksLists the tasks of a directory (paginated, optionally filtered)Try it - POST
…/tasks/searchSearches tasks (parameters in the body)Try it - GET
…/tasks/{taskId}Returns a single task by idTry it - POST
…/tasksCreates a taskTry it - PATCH
…/tasks/{taskId}Updates a task (partial update)Try it - POST
…/tasks/{taskId}/doneToggles the done status of a taskTry it - DELETE
…/tasks/{taskId}Deletes a task (soft-delete)Try it - GET
…/task-categoriesLists the task categories of a directory (resolves `taskCategoryId`)Try it