How to get meeting summaries from the Sally API
Getting summaries is the most common reason to use the Sally API: take what Sally understood about a meeting and put it where your team already works. This guide shows how a summary is structured, how to fetch or search it, which status fields tell you whether there is something to read, how to download it as a finished PDF or Word file, and ends with a complete script that imports every new summary since its last run.
Every example works as it stands once SALLY_TOKEN and DIRECTORY_ID (plus RECORDING_ID where a recording is needed) are set, see the Quickstart. Pick your language once, every example on the API pages follows.
Read the Quickstart and Meetings, recordings & summaries first.
Quick navigation
- What a summary contains
- Fetch the summaries of a recording
- Fetch a single summary
- Search summaries
- Check that there is something to read
- Download as a file
- Complete script: import new summaries
- Endpoints at a glance
1. What a summary contains
A summary belongs to exactly one recording. A recording can have several summaries, one per language. Each summary consists of a summary text (summary) and a list of section items (sectionItems). The sections come from the meeting template that was applied, so a sales call and a technical review produce different sections.
| Field | Type | Description |
|---|---|---|
recordingSummaryId | string | Id of the summary. |
recordingId | string | Id of the recording the summary was generated from. |
appointmentId | string | null | Id of the linked appointment, null if there is none. |
languageCode | string | null | Language of the summary as an IETF language tag, e.g. de-DE or en-US. |
summary | string | null | The summary text. |
isSummarizationCompleted | boolean | true once the summarization run has finished, whatever the outcome. |
isSummarizationSucceeded | boolean | true when there is something to read. |
summarizationCompletionReason | string | Why the run ended, see Check that there is something to read. |
createdOn | string | Creation timestamp (ISO-8601). |
sectionItems | array | null | The generated section items, null unless loaded. |
Each section item carries its sectionType and its content. The field outputFormat tells you whether that content is html or markdown. Task-like items additionally carry subject, description, responsibleUserName, responsibleUserEmail and dueDate. The section types table, appointment and email keep their result in dedicated fields and also put a readable Markdown version of it into content, so a client that only reads content still shows every section.
| Field | Type | Description |
|---|---|---|
sectionItemId | string | Id of the section item. |
meetingTemplateSectionId | string | null | Id of the meeting template section the item was generated from, null for derived items such as task items. |
title | string | Title of the section item. |
sectionType | string | summary, tasks, topics, decisions, customList, freeText, table, appointment, email or unknown. |
outputFormat | string | Format of content: html, markdown or unknown. |
sortOrder | number | Order within the summary. |
content | string | null | The content of the item. For table, appointment and email a readable Markdown version of the dedicated fields, outputFormat is then markdown. |
subject | string | null | Task subject, for task-like items. |
description | string | null | Task description. |
responsibleUserName | string | null | Name of the responsible person. |
responsibleUserEmail | string | null | Email of the responsible person. |
dueDate | string | null | Due date (ISO-8601). |
tableVariant | string | null | Only for table: structured when the template fixed the columns and the result is in tableData, free when Sally chose the columns and the result is a Markdown table in content. |
tableData | object | null | Columns and rows of a structured table, otherwise null. |
followUpMeetingStartTime | string | null | Only for appointment: start of the proposed follow-up meeting as a local time without time zone, e.g. 2026-10-16T09:00:00. Apply the time zone of the people involved. |
followUpMeetingEndTime | string | null | End in the same format, null when the meeting named only a start. |
followUpMeetingParticipants | array | null | Only for appointment: the people named for the follow-up, each with userId, name, email and isExternal. |
emailRecipients | array | null | Only for email: the recipients with userId, name, email and kind (to or cc). Subject and body of the email are in subject and description. |
2. Fetch the summaries of a recording
When you know the recording, ask it for its summaries. Add includeDetails=true, otherwise sectionItems is null:
| Parameter | Type | Default | Description |
|---|---|---|---|
includeDetails | boolean | false | When true, every summary also carries its sectionItems. |
fields | string | all fields | Comma-separated list of the fields to return, see Field selection. |
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/summaries?includeDetails=true" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/summaries`,
);
url.searchParams.set('includeDetails', 'true');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const summaries = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/summaries",
params={"includeDetails": "true"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
summaries = response.json()
The answer is a plain JSON array, not paged, with one entry per language:
[
{
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "de-DE",
"summary": "The team agreed on the rollout plan for October and assigned the open tasks.",
"isSummarizationCompleted": true,
"isSummarizationSucceeded": true,
"summarizationCompletionReason": "succeeded",
"createdOn": "2026-09-24T09:30:00Z",
"sectionItems": [
{
"sectionItemId": "39ec96ee-9578-4082-a103-aa168fcd18f6",
"meetingTemplateSectionId": "0ba1ea56-516d-485a-a738-e65e51578bbe",
"title": "Decisions",
"sectionType": "decisions",
"outputFormat": "html",
"sortOrder": 1,
"content": "We agreed on the rollout plan for October.",
"subject": null,
"description": null,
"responsibleUserName": null,
"responsibleUserEmail": null,
"dueDate": null,
"tableVariant": null,
"tableData": null,
"followUpMeetingStartTime": null,
"followUpMeetingEndTime": null,
"followUpMeetingParticipants": null,
"emailRecipients": null
}
]
}
]
Section items make up most of the payload. If you only need the summary text, leave out includeDetails. Field selection shows how to trim responses further.
3. Fetch a single summary
When you already have a recordingSummaryId, for example from a search hit, fetch that summary directly. This endpoint always includes sectionItems, no parameter needed. It answers 404 if the summary does not exist or you cannot access the underlying recording.
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/summaries/$RECORDING_SUMMARY_ID" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/summaries/${recordingSummaryId}`,
{headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
const summary = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/summaries/{recording_summary_id}",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
summary = response.json()
The answer is a single object with the same fields as an entry in section 2.
4. Search summaries
The search covers the summary text, the generated section items (title, content, subject and description) and the cells of structured tables in one go. A recording is a hit if any of them matches. The search term needs at least 3 characters and goes into the request body:
| Field | Type | Default | Description |
|---|---|---|---|
search | string | Case-insensitive substring, at least 3 characters. | |
page | number | 1 | Page number, starting at 1. |
pageSize | number | 25 | Entries per page, at most 100. |
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/summaries/search" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"search": "pricing",
"page": 1,
"pageSize": 25
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/summaries/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({search: 'pricing', page: 1, pageSize: 25}),
},
);
const {total, hasMore, items} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/summaries/search",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={"search": "pricing", "page": 1, "pageSize": 25},
)
data = response.json()
Each hit returns a short snippet plus the ids you need to fetch the full summary:
{
"page": 1,
"pageSize": 25,
"total": 3,
"hasMore": false,
"items": [
{
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "de-DE",
"snippet": "The team discussed the new pricing model and agreed to test it with two customers.",
"createdOn": "2026-09-24T09:30:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
recordingSummaryId | string | Id of the summary. Pass it to Fetch a single summary. |
recordingId | string | Id of the recording. |
appointmentId | string | null | Id of the linked appointment, if there is one. |
languageCode | string | null | Language of the summary. |
snippet | string | Truncated excerpt of the summary text, at most 300 characters. |
createdOn | string | Creation timestamp (ISO-8601). |
The response is paged like every list, and Pagination & search explains why search takes its parameters in the body.
5. Check that there is something to read
isSummarizationCompletedsays the run has ended, also when it produced nothing.isSummarizationSucceededsays there is something to read: a summary text or at least one section item. This is the field to check.summarizationCompletionReasonsays why the run ended.
summarizationCompletionReason | Meaning |
|---|---|
succeeded | There is a summary text or at least one section item. |
noTranscript | There was nothing to summarize. An expected outcome, not a failure. |
emptyResult | The run finished without a result. |
error | The run failed. The underlying error text is deliberately not exposed. |
unknown | The run has not finished yet. |
A nightly import lists the recordings created since its last run with createdAfter, fetches their summaries and skips every entry where isSummarizationSucceeded is false. It stores the recordingId together with the languageCode as its key, never the appointment. The next section shows exactly that.
6. Download as a file
Sometimes you do not need the data but the finished document, for example to attach it to a CRM entry or file it in a document management system. GET …/summaries/{recordingSummaryId}/document returns exactly the file a user downloads in the Sally web app: with branding, logos, header and footer, tables, the section order of the meeting template and the same file name. The file is built on request and comes back directly in the response, there is no job to poll.
The document always contains the complete summary in the language version of this recordingSummaryId. A recording has one summary per language, so pick the right id first, for example from section 2. Custom insights and single sections are not part of the document.
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | yes | pdf, docx, odt, txt or md. |
languageCode | string | no | de-DE, en-US, es-ES, fr-FR, it-IT, nl-NL or pl-PL. Without it, the language of the token owner applies, as in the web app. |
- cURL
- JavaScript
- Python
# -OJ saves the file under the name from Content-Disposition
curl -fOJ "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/summaries/$RECORDING_SUMMARY_ID/document?format=pdf&languageCode=en-US" \
-H "Authorization: Bearer $SALLY_TOKEN"
import {writeFile} from 'node:fs/promises';
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/summaries/${recordingSummaryId}/document`,
);
url.searchParams.set('format', 'pdf');
url.searchParams.set('languageCode', 'en-US');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
// Same file name as in the web app, UTF-8 version from Content-Disposition
const disposition = response.headers.get('content-disposition') ?? '';
const match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
const fileName = match ? decodeURIComponent(match[1]) : 'summary.pdf';
await writeFile(fileName, Buffer.from(await response.arrayBuffer()));
import os
import re
from urllib.parse import unquote
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/summaries/{recording_summary_id}/document",
params={"format": "pdf", "languageCode": "en-US"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
# Same file name as in the web app, UTF-8 version from Content-Disposition
disposition = response.headers.get("Content-Disposition", "")
match = re.search(r"filename\*=UTF-8''([^;]+)", disposition, re.IGNORECASE)
file_name = unquote(match.group(1)) if match else "summary.pdf"
with open(file_name, "wb") as f:
f.write(response.content)
languageCode only changes the labels and the date format inside the document, not the content. The font always follows the language of the recording, so Chinese characters stay readable with en-US labels. This endpoint does not support ?fields=.
The answer is 200 with the file itself, not JSON. The header Content-Disposition carries the file name the web app uses for the same download, ASCII in filename and UTF-8 in filename*. The Content-Type follows the format:
format | Content-Type |
|---|---|
pdf | application/pdf |
docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
odt | application/vnd.oasis.opendocument.text |
txt | text/plain; charset=utf-8 |
md | text/markdown; charset=utf-8 |
Besides the usual errors, this endpoint answers with:
| Status | code | Meaning |
|---|---|---|
400 | UNKNOWN_DOCUMENT_FORMAT or UNKNOWN_LANGUAGE_CODE | format is missing or unknown, or languageCode is unknown. The body lists the allowed values. |
403 | SUMMARY_DOWNLOAD_PREVENTED | The company account has disabled this download for its members. |
404 | The summary does not exist, belongs to another company account, or you cannot access the underlying recording. | |
409 | SUMMARY_HAS_NO_CONTENT | The summary has no content for a document, because it was generated before meeting templates existed or its template has no sections. |
Errors shows how to handle these codes in one place.
7. Complete script: import new summaries
This script imports every summary created since its last run. It remembers two things between runs: when it last ran, and which recordings were still being processed. Those are checked again next time, so a summary that was not ready yet is not lost.
For every recording it decides:
- Transcription still running: keep it for the next run.
- Transcription finished without a transcript: skip it, there will be no summary.
- Summaries not finished: keep it for the next run.
- Otherwise: import every summary where
isSummarizationSucceededistrue, keyed byrecordingIdpluslanguageCode.
The script writes the summaries to a local file. Replace that part with a call to your CRM, wiki or ticket system.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs curl and jq. curl --retry repeats a request after a 429 with growing waits.
set -euo pipefail
API="https://api.sally.io/v1.0/directories/$DIRECTORY_ID"
AUTH="Authorization: Bearer $SALLY_TOKEN"
CURL=(curl -sf --retry 5 -H "$AUTH")
since=$(cat .sally-last-run 2>/dev/null || echo "2026-01-01T00:00:00Z")
started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
touch .sally-pending
# New recordings since the last run, plus the ones still pending
ids=$(cat .sally-pending)
page=1
while : ; do
body=$("${CURL[@]}" -G "$API/recordings" \
--data-urlencode "createdAfter=$since" \
-d "page=$page" -d "pageSize=100" -d "fields=recordingId")
ids="$ids"$'\n'"$(echo "$body" | jq -r '.items[].recordingId')"
[ "$(echo "$body" | jq '.hasMore')" = "true" ] || break
page=$((page + 1))
done
: > .sally-pending.next
for id in $(echo "$ids" | grep -v '^$' | sort -u); do
rec=$("${CURL[@]}" "$API/recordings/$id?fields=isTranscriptionCompleted,isTranscriptionSucceeded")
if [ "$(echo "$rec" | jq '.isTranscriptionCompleted')" != "true" ]; then
echo "$id" >> .sally-pending.next; continue
fi
[ "$(echo "$rec" | jq '.isTranscriptionSucceeded')" = "true" ] || continue
summaries=$("${CURL[@]}" "$API/recordings/$id/summaries?includeDetails=true")
if [ "$(echo "$summaries" | jq 'length > 0 and all(.[]; .isSummarizationCompleted)')" != "true" ]; then
echo "$id" >> .sally-pending.next; continue
fi
# Replace this line with the call to your target system
echo "$summaries" | jq -c '.[] | select(.isSummarizationSucceeded)' >> summaries.jsonl
done
mv .sally-pending.next .sally-pending
echo "$started" > .sally-last-run
import {readFile, writeFile, appendFile} from 'node:fs/promises';
const API = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}`;
const STATE_FILE = '.sally-import.json';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function api(path, params = {}) {
const url = new URL(`${API}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
for (let wait = 1000; ; wait = Math.min(wait * 2, 60_000)) {
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (response.status === 429) {
await sleep(wait);
continue;
}
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
}
// Replace this function with the call to your target system
async function save(key, summary) {
await appendFile('summaries.jsonl', JSON.stringify({key, ...summary}) + '\n');
}
const state = JSON.parse(
await readFile(STATE_FILE, 'utf8').catch(() => '{"since":"2026-01-01T00:00:00Z","pending":[]}'),
);
// createdAfter expects YYYY-MM-DDTHH:MM:SSZ, without milliseconds
const started = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
// New recordings since the last run, plus the ones still pending
const ids = new Set(state.pending);
for (let page = 1; ; page++) {
const body = await api('/recordings', {
createdAfter: state.since,
page,
pageSize: 100,
fields: 'recordingId',
});
body.items.forEach((item) => ids.add(item.recordingId));
if (!body.hasMore) break;
}
const pending = [];
for (const recordingId of ids) {
const recording = await api(`/recordings/${recordingId}`, {
fields: 'isTranscriptionCompleted,isTranscriptionSucceeded',
});
if (!recording.isTranscriptionCompleted) {
pending.push(recordingId);
continue;
}
if (!recording.isTranscriptionSucceeded) continue; // no transcript, no summary
const summaries = await api(`/recordings/${recordingId}/summaries`, {includeDetails: true});
if (summaries.length === 0 || summaries.some((s) => !s.isSummarizationCompleted)) {
pending.push(recordingId);
continue;
}
for (const summary of summaries) {
if (summary.isSummarizationSucceeded) {
await save(`${summary.recordingId}:${summary.languageCode}`, summary);
}
}
}
await writeFile(STATE_FILE, JSON.stringify({since: started, pending}));
import json
import os
import time
from datetime import datetime, timezone
import requests
API = f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}"
STATE_FILE = ".sally-import.json"
def api(path, params=None):
wait = 1
while True:
response = requests.get(
f"{API}{path}",
params=params,
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
if response.status_code == 429:
time.sleep(wait)
wait = min(wait * 2, 60)
continue
response.raise_for_status()
return response.json()
# Replace this function with the call to your target system
def save(key, summary):
with open("summaries.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, **summary}) + "\n")
try:
with open(STATE_FILE, encoding="utf-8") as f:
state = json.load(f)
except FileNotFoundError:
state = {"since": "2026-01-01T00:00:00Z", "pending": []}
# createdAfter expects YYYY-MM-DDTHH:MM:SSZ
started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# New recordings since the last run, plus the ones still pending
ids = set(state["pending"])
page = 1
while True:
body = api("/recordings", {
"createdAfter": state["since"],
"page": page,
"pageSize": 100,
"fields": "recordingId",
})
ids.update(item["recordingId"] for item in body["items"])
if not body["hasMore"]:
break
page += 1
pending = []
for recording_id in ids:
recording = api(f"/recordings/{recording_id}", {
"fields": "isTranscriptionCompleted,isTranscriptionSucceeded",
})
if not recording["isTranscriptionCompleted"]:
pending.append(recording_id)
continue
if not recording["isTranscriptionSucceeded"]:
continue # no transcript, no summary
summaries = api(f"/recordings/{recording_id}/summaries", {"includeDetails": "true"})
if not summaries or any(not s["isSummarizationCompleted"] for s in summaries):
pending.append(recording_id)
continue
for summary in summaries:
if summary["isSummarizationSucceeded"]:
save(f"{summary['recordingId']}:{summary['languageCode']}", summary)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump({"since": started, "pending": pending}, f)
Run the script on a schedule, for example every night. If you would rather hear about each summary right away, register a webhook for summary.ready. Its payload has the same shape as the summaries above, so one parser serves both paths.
8. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
- GET
…/recordingsLists recordings of a directory (paginated, optionally filtered by date)Try it - GET
…/recordings/{recordingId}Returns a single recordingTry it - GET
…/recordings/{recordingId}/summariesReturns the summaries of a recordingTry it - GET
…/summaries/{recordingSummaryId}Returns a single recording summary by idTry it - POST
…/summaries/searchSearches recording summaries (parameters in the body)Try it - GET
…/summaries/{recordingSummaryId}/documentReturns a recording summary as a finished document fileTry it