Wie du Meeting-Zusammenfassungen über die Sally API abrufst
Zusammenfassungen abzurufen ist der häufigste Grund, die Sally API zu nutzen: Du bringst das, was Sally vom Meeting verstanden hat, dorthin, wo dein Team ohnehin arbeitet. Diese Anleitung zeigt, wie eine Zusammenfassung aufgebaut ist, wie du sie abrufst oder durchsuchst, an welchen Statusfeldern du erkennst, ob es etwas zu lesen gibt, wie du sie als fertige PDF- oder Word-Datei herunterlädst, und endet mit einem vollständigen Skript, das alle neuen Zusammenfassungen seit dem letzten Lauf importiert.
Jedes Beispiel funktioniert so, wie es dasteht, sobald SALLY_TOKEN und DIRECTORY_ID (und wo eine Aufnahme gebraucht wird, RECORDING_ID) gesetzt sind, siehe Schnellstart. Wähl deine Sprache einmal, alle Beispiele im API-Bereich folgen ihr.
Lies zuerst den Schnellstart und Termine, Aufnahmen und Zusammenfassungen.
Schnellnavigation
- Was eine Zusammenfassung enthält
- Die Zusammenfassungen einer Aufnahme abrufen
- Eine einzelne Zusammenfassung abrufen
- Zusammenfassungen durchsuchen
- Prüfen, ob es etwas zu lesen gibt
- Als Datei herunterladen
- Vollständiges Skript: neue Zusammenfassungen importieren
- Endpunkte im Überblick
1. Was eine Zusammenfassung enthält
Eine Zusammenfassung gehört zu genau einer Aufnahme. Eine Aufnahme kann mehrere Zusammenfassungen haben, eine je Sprache. Jede besteht aus einem Zusammenfassungstext (summary) und einer Liste von Abschnittseinträgen (sectionItems). Die Abschnitte stammen aus dem angewendeten Meetingtemplate, ein Vertriebsgespräch liefert also andere Abschnitte als ein technisches Review.
| Feld | Typ | Beschreibung |
|---|---|---|
recordingSummaryId | string | Id der Zusammenfassung. |
recordingId | string | Id der Aufnahme, aus der die Zusammenfassung erzeugt wurde. |
appointmentId | string | null | Id des verknüpften Termins, null, wenn es keinen gibt. |
languageCode | string | null | Sprache der Zusammenfassung als IETF-Sprachtag, z. B. de-DE oder en-US. |
summary | string | null | Der Zusammenfassungstext. |
isSummarizationCompleted | boolean | true, sobald der Lauf beendet ist, egal mit welchem Ergebnis. |
isSummarizationSucceeded | boolean | true, wenn es etwas zu lesen gibt. |
summarizationCompletionReason | string | Warum der Lauf endete, siehe Prüfen, ob es etwas zu lesen gibt. |
createdOn | string | Zeitpunkt der Erstellung (ISO-8601). |
sectionItems | array | null | Die erzeugten Abschnittseinträge, null, wenn sie nicht mitgeladen wurden. |
Jeder Abschnittseintrag trägt seinen sectionType und seinen Inhalt in content. Das Feld outputFormat sagt dir, ob dieser Inhalt html oder markdown ist. Aufgabenartige Einträge tragen zusätzlich subject, description, responsibleUserName, responsibleUserEmail und dueDate. Die Abschnittstypen table, appointment und email legen ihr Ergebnis in eigenen Feldern ab und schreiben zusätzlich eine lesbare Markdown-Fassung davon in content. So zeigt auch ein Client, der nur content liest, jeden Abschnitt an.
| Feld | Typ | Beschreibung |
|---|---|---|
sectionItemId | string | Id des Abschnittseintrags. |
meetingTemplateSectionId | string | null | Id des Template-Abschnitts, aus dem der Eintrag entstand, null bei abgeleiteten Einträgen wie Aufgaben. |
title | string | Titel des Abschnittseintrags. |
sectionType | string | summary, tasks, topics, decisions, customList, freeText, table, appointment, email oder unknown. |
outputFormat | string | Format von content: html, markdown oder unknown. |
sortOrder | number | Reihenfolge innerhalb der Zusammenfassung. |
content | string | null | Der Inhalt des Eintrags. Bei table, appointment und email eine lesbare Markdown-Fassung der eigenen Felder, outputFormat ist dann markdown. |
subject | string | null | Betreff der Aufgabe, bei aufgabenartigen Einträgen. |
description | string | null | Beschreibung der Aufgabe. |
responsibleUserName | string | null | Name der verantwortlichen Person. |
responsibleUserEmail | string | null | E-Mail der verantwortlichen Person. |
dueDate | string | null | Fälligkeitsdatum (ISO-8601). |
tableVariant | string | null | Nur bei table: structured, wenn das Template die Spalten festlegt und das Ergebnis in tableData steht, free, wenn Sally die Spalten selbst gewählt hat und das Ergebnis eine Markdown-Tabelle in content ist. |
tableData | object | null | Spalten und Zeilen einer structured-Tabelle, sonst null. |
followUpMeetingStartTime | string | null | Nur bei appointment: Beginn des vorgeschlagenen Folgetermins als lokale Uhrzeit ohne Zeitzone, z. B. 2026-10-16T09:00:00. Wende die Zeitzone der Beteiligten an. |
followUpMeetingEndTime | string | null | Ende im selben Format, null, wenn im Meeting nur ein Beginn genannt wurde. |
followUpMeetingParticipants | array | null | Nur bei appointment: die für den Folgetermin genannten Personen, jeweils mit userId, name, email und isExternal. |
emailRecipients | array | null | Nur bei email: die Empfänger mit userId, name, email und kind (to oder cc). Betreff und Text der E-Mail stehen in subject und description. |
2. Die Zusammenfassungen einer Aufnahme abrufen
Wenn du die Aufnahme kennst, frag sie nach ihren Zusammenfassungen. Hänge includeDetails=true an, sonst ist sectionItems gleich null:
| Parameter | Typ | Standard | Beschreibung |
|---|---|---|---|
includeDetails | boolean | false | Bei true trägt jede Zusammenfassung zusätzlich ihre sectionItems. |
fields | string | alle Felder | Kommagetrennte Liste der gewünschten Felder, siehe Feldauswahl. |
- 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()
Die Antwort ist ein einfaches JSON-Array, nicht geblättert, mit einem Eintrag je Sprache:
[
{
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "de-DE",
"summary": "Das Team hat sich auf den Rollout-Plan für Oktober geeinigt und die offenen Aufgaben verteilt.",
"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": "Entscheidungen",
"sectionType": "decisions",
"outputFormat": "html",
"sortOrder": 1,
"content": "Wir haben uns auf den Rollout-Plan für Oktober geeinigt.",
"subject": null,
"description": null,
"responsibleUserName": null,
"responsibleUserEmail": null,
"dueDate": null,
"tableVariant": null,
"tableData": null,
"followUpMeetingStartTime": null,
"followUpMeetingEndTime": null,
"followUpMeetingParticipants": null,
"emailRecipients": null
}
]
}
]
Abschnittseinträge machen den Großteil der Antwort aus. Wenn du nur den Zusammenfassungstext brauchst, lass includeDetails weg. Wie du Antworten noch weiter verkleinerst, zeigt Feldauswahl.
3. Eine einzelne Zusammenfassung abrufen
Wenn du schon eine recordingSummaryId hast, etwa aus einem Suchtreffer, rufst du diese Zusammenfassung direkt ab. Dieser Endpunkt liefert sectionItems immer mit, ohne Parameter. Er antwortet mit 404, wenn die Zusammenfassung nicht existiert oder du keinen Zugriff auf die zugehörige Aufnahme hast.
- 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()
Die Antwort ist ein einzelnes Objekt mit denselben Feldern wie ein Eintrag aus Abschnitt 2.
4. Zusammenfassungen durchsuchen
Die Suche deckt den Zusammenfassungstext, die erzeugten Abschnittseinträge (Titel, Inhalt, Betreff und Beschreibung) und die Zellen strukturierter Tabellen in einem Rutsch ab. Eine Aufnahme ist ein Treffer, wenn irgendeines davon passt. Der Suchbegriff braucht mindestens 3 Zeichen und steht im Request-Body:
| Feld | Typ | Standard | Beschreibung |
|---|---|---|---|
search | string | Teilstring ohne Rücksicht auf Groß- und Kleinschreibung, mindestens 3 Zeichen. | |
page | number | 1 | Seitennummer, beginnend bei 1. |
pageSize | number | 25 | Einträge je Seite, höchstens 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": "Preisgestaltung",
"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: 'Preisgestaltung', 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": "Preisgestaltung", "page": 1, "pageSize": 25},
)
data = response.json()
Jeder Treffer liefert einen kurzen snippet und die Ids, mit denen du die vollständige Zusammenfassung abrufst:
{
"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": "Das Team hat die neue Preisgestaltung besprochen und will sie mit zwei Kunden testen.",
"createdOn": "2026-09-24T09:30:00Z"
}
]
}
| Feld | Typ | Beschreibung |
|---|---|---|
recordingSummaryId | string | Id der Zusammenfassung. Gib sie an Eine einzelne Zusammenfassung abrufen weiter. |
recordingId | string | Id der Aufnahme. |
appointmentId | string | null | Id des verknüpften Termins, falls vorhanden. |
languageCode | string | null | Sprache der Zusammenfassung. |
snippet | string | Gekürzter Auszug aus dem Zusammenfassungstext, höchstens 300 Zeichen. |
createdOn | string | Zeitpunkt der Erstellung (ISO-8601). |
Die Antwort ist geblättert wie jede Liste. Warum die Suche ihre Parameter im Body erwartet, erklärt Paginierung und Suche.
5. Prüfen, ob es etwas zu lesen gibt
isSummarizationCompletedsagt, dass der Lauf beendet ist, auch wenn er nichts ergeben hat.isSummarizationSucceededsagt, dass es etwas zu lesen gibt: einen Zusammenfassungstext oder mindestens einen Abschnittseintrag. Dieses Feld solltest du prüfen.summarizationCompletionReasonsagt, warum der Lauf endete.
summarizationCompletionReason | Bedeutung |
|---|---|
succeeded | Es gibt einen Zusammenfassungstext oder mindestens einen Abschnittseintrag. |
noTranscript | Es gab nichts zusammenzufassen. Ein erwartetes Ergebnis, kein Fehler. |
emptyResult | Der Lauf endete ohne Ergebnis. |
error | Der Lauf ist fehlgeschlagen. Der eigentliche Fehlertext wird bewusst nicht herausgegeben. |
unknown | Der Lauf ist noch nicht beendet. |
Ein nächtlicher Import listet mit createdAfter die seit dem letzten Lauf entstandenen Aufnahmen, holt ihre Zusammenfassungen und überspringt jeden Eintrag, bei dem isSummarizationSucceeded gleich false ist. Als Schlüssel speichert er die recordingId zusammen mit dem languageCode, nie den Termin. Genau das zeigt der nächste Abschnitt.
6. Als Datei herunterladen
Manchmal brauchst du nicht die Daten, sondern das fertige Dokument, etwa um es an einen CRM-Eintrag zu hängen oder in einem Dokumentenmanagementsystem abzulegen. GET …/summaries/{recordingSummaryId}/document liefert genau die Datei, die ein Nutzer in der Sally-Weboberfläche herunterlädt: mit Branding, Logos, Kopf- und Fußzeile, Tabellen, der Abschnittsreihenfolge des Meetingtemplates und demselben Dateinamen. Die Datei wird bei der Anfrage erzeugt und kommt direkt in der Antwort zurück, es gibt keinen Auftrag, den du abfragen musst.
Das Dokument enthält immer die vollständige Zusammenfassung in der Sprachfassung dieser recordingSummaryId. Eine Aufnahme hat eine Zusammenfassung pro Sprache, such dir also zuerst die passende Id heraus, zum Beispiel über Abschnitt 2. Eigene Einblicke und einzelne Abschnitte sind nicht Teil des Dokuments.
| Parameter | Typ | Pflicht | Beschreibung |
|---|---|---|---|
format | string | ja | pdf, docx, odt, txt oder md. |
languageCode | string | nein | de-DE, en-US, es-ES, fr-FR, it-IT, nl-NL oder pl-PL. Ohne Angabe gilt die Sprache des Token-Inhabers, wie in der Weboberfläche. |
- cURL
- JavaScript
- Python
# -OJ speichert die Datei unter dem Namen aus Content-Disposition
curl -fOJ "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/summaries/$RECORDING_SUMMARY_ID/document?format=pdf&languageCode=de-DE" \
-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', 'de-DE');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
// Derselbe Dateiname wie in der Weboberfläche, UTF-8-Fassung aus Content-Disposition
const disposition = response.headers.get('content-disposition') ?? '';
const match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
const fileName = match ? decodeURIComponent(match[1]) : 'zusammenfassung.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": "de-DE"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
# Derselbe Dateiname wie in der Weboberfläche, UTF-8-Fassung aus 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 "zusammenfassung.pdf"
with open(file_name, "wb") as f:
f.write(response.content)
languageCode ändert nur die Beschriftungen und das Datumsformat im Dokument, nicht den Inhalt. Die Schrift richtet sich immer nach der Sprache der Aufnahme, chinesische Schriftzeichen bleiben also auch mit en-US-Beschriftungen lesbar. ?fields= wird bei diesem Endpunkt nicht unterstützt.
Die Antwort ist 200 mit der Datei selbst, kein JSON. Der Header Content-Disposition enthält den Dateinamen, den die Weboberfläche für denselben Download verwendet, in ASCII in filename und in UTF-8 in filename*. Der Content-Type richtet sich nach dem 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 |
Neben den üblichen Fehlern antwortet dieser Endpunkt mit:
| Status | code | Bedeutung |
|---|---|---|
400 | UNKNOWN_DOCUMENT_FORMAT oder UNKNOWN_LANGUAGE_CODE | format fehlt oder ist unbekannt, oder languageCode ist unbekannt. Der Body nennt die erlaubten Werte. |
403 | SUMMARY_DOWNLOAD_PREVENTED | Das Unternehmenskonto hat diesen Download für seine Mitglieder gesperrt. |
404 | Die Zusammenfassung gibt es nicht, sie gehört zu einem anderen Unternehmenskonto, oder du hast keinen Zugriff auf die zugrunde liegende Aufnahme. | |
409 | SUMMARY_HAS_NO_CONTENT | Die Zusammenfassung hat keinen Inhalt für ein Dokument, weil sie entstand, bevor es Meetingtemplates gab, oder ihr Template keine Abschnitte hat. |
Unter Fehler siehst du, wie du diese Codes an einer Stelle behandelst.
7. Vollständiges Skript: neue Zusammenfassungen importieren
Dieses Skript importiert alle Zusammenfassungen, die seit seinem letzten Lauf entstanden sind. Zwischen zwei Läufen merkt es sich zwei Dinge: wann es zuletzt lief und welche Aufnahmen noch in Bearbeitung waren. Diese prüft es beim nächsten Mal erneut, eine Zusammenfassung, die noch nicht fertig war, geht also nicht verloren.
Für jede Aufnahme entscheidet es:
- Transkription läuft noch: für den nächsten Lauf vormerken.
- Transkription beendet, aber ohne Transkript: überspringen, es wird keine Zusammenfassung geben.
- Zusammenfassungen noch nicht fertig: für den nächsten Lauf vormerken.
- Sonst: jede Zusammenfassung mit
isSummarizationSucceededgleichtrueimportieren, mitrecordingIdpluslanguageCodeals Schlüssel.
Das Skript schreibt die Zusammenfassungen in eine lokale Datei. Ersetze diesen Teil durch den Aufruf deines CRM, Wikis oder Ticketsystems.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Braucht curl und jq. curl --retry wiederholt eine Anfrage nach einem 429 mit wachsenden Pausen.
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
# Neue Aufnahmen seit dem letzten Lauf, dazu die noch vorgemerkten
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
# Diese Zeile durch den Aufruf deines Zielsystems ersetzen
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();
}
}
// Diese Funktion durch den Aufruf deines Zielsystems ersetzen
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 erwartet YYYY-MM-DDTHH:MM:SSZ, ohne Millisekunden
const started = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
// Neue Aufnahmen seit dem letzten Lauf, dazu die noch vorgemerkten
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; // kein Transkript, keine Zusammenfassung
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()
# Diese Funktion durch den Aufruf deines Zielsystems ersetzen
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 erwartet YYYY-MM-DDTHH:MM:SSZ
started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Neue Aufnahmen seit dem letzten Lauf, dazu die noch vorgemerkten
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 # kein Transkript, keine Zusammenfassung
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)
Lass das Skript regelmäßig laufen, zum Beispiel jede Nacht. Willst du von jeder Zusammenfassung sofort erfahren, registrier einen Webhook für summary.ready. Seine Nutzdaten haben dieselbe Form wie die Zusammenfassungen oben, ein Parser reicht also für beide Wege.
8. Endpunkte im Überblick
Jeder Eintrag führt in die Referenz, wo du die Anfrage mit deinem eigenen Token abschicken kannst.
- GET
…/recordingsListet die Aufnahmen eines Unternehmenskontos auf (paginiert, optional nach Datum gefiltert)Ausprobieren - GET
…/recordings/{recordingId}Gibt eine einzelne Aufnahme zurückAusprobieren - GET
…/recordings/{recordingId}/summariesGibt die Zusammenfassungen einer Aufnahme zurückAusprobieren - GET
…/summaries/{recordingSummaryId}Gibt eine einzelne Zusammenfassung anhand ihrer Id zurückAusprobieren - POST
…/summaries/searchDurchsucht Zusammenfassungen (Parameter im Body)Ausprobieren - GET
…/summaries/{recordingSummaryId}/documentGibt eine Zusammenfassung als fertige Dokumentdatei zurückAusprobieren