Wie du Ereignisse per Webhook empfängst
Statt alle paar Minuten nachzufragen, ob ein Meeting fertig ist, lässt du es dir von Sally sagen. Ein Webhook schickt einen POST-Request an deine URL, sobald eine Zusammenfassung fertig ist. Das ist der Unterschied zwischen einer Anbindung, die sofort wirkt, und einer, die sich wie ein nächtlicher Lauf anfühlt.
Diese Anleitung registriert einen Webhook, baut einen kleinen Empfänger in Node und Python und zeigt, wie du Webhooks pausierst, änderst und löschst. Jedes Beispiel funktioniert so, wie es dasteht, sobald SALLY_TOKEN und DIRECTORY_ID gesetzt sind, siehe Schnellstart. Hilfreich ist, wenn du die Form einer Zusammenfassung aus Zusammenfassungen abrufen kennst.
Schnellnavigation
- Webhook registrieren
- Die Ereignisse
- Persönlich oder unternehmensweit
- Die Zustellung empfangen
- Auf deiner Seite
- Webhook pausieren, ändern oder löschen
- Komplettes Einrichtungsskript
- Endpunkte im Überblick
1. Webhook registrieren
POST/v1.0/directories/{directoryId}/webhooks registriert einen Webhook. Der Body nimmt diese Felder:
| Feld | Typ | Standard | Beschreibung |
|---|---|---|---|
url | string | Pflicht. Der Endpunkt, an den Sally den POST-Request schickt. | |
event | string | summary.ready | Wann der Webhook feuert: summary.ready oder manual, siehe Die Ereignisse. |
name | string | Eine Bezeichnung, an der du den Webhook in der Liste erkennst. | |
directoryWide | boolean | false | false feuert nur für deine eigenen Aufnahmen, true für die Aufnahmen aller Nutzer. Siehe Persönlich oder unternehmensweit. |
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/webhooks" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/sally",
"event": "summary.ready",
"name": "CRM-Sync"
}'
const response = await fetch(`https://api.sally.io/v1.0/directories/${directoryId}/webhooks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/hooks/sally',
event: 'summary.ready',
name: 'CRM-Sync',
}),
});
const webhook = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/webhooks",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"url": "https://example.com/hooks/sally",
"event": "summary.ready",
"name": "CRM-Sync",
},
)
webhook = response.json()
Die Antwort ist der neue Webhook:
{
"webhookId": "af39409b-ca90-443d-a5e8-47dfd97c518f",
"name": "CRM-Sync",
"event": "summary.ready",
"url": "https://example.com/hooks/sally",
"isActive": true,
"directoryWide": false,
"createdOn": "2026-09-24T09:30:00Z"
}
| Feld | Typ | Bedeutung |
|---|---|---|
webhookId | string | Id des Webhooks. Heb sie auf, um den Webhook später zu pausieren, zu ändern oder zu löschen. |
name | string | Anzeigename des Webhooks. |
event | string | Wann der Webhook feuert. |
url | string | Ziel-URL, an die Sally das Ereignis schickt. |
isActive | boolean | Ob der Webhook gerade feuert. |
directoryWide | boolean | true für alle Nutzer des Unternehmenskontos, false nur für deine eigenen Aufnahmen. |
createdOn | string | Zeitpunkt der Erstellung (ISO 8601). |
2. Die Ereignisse
summary.readyfeuert nach jeder Zusammenfassung. Das ist der Standard und meist genau das, was du willst.manualfeuert nie von selbst. Eine Person startet es aus einem Meeting oder einer Aufnahme heraus. Das lohnt sich, wenn jemand entscheiden soll, was verschickt wird.
Webhooks aus der Zeit vor dem heutigen Modell melden möglicherweise noch die älteren Ereignisse recording.summarized oder meeting.summarized. Sie bleiben lesbar, lassen sich aber nicht mehr neu setzen.
3. Persönlich oder unternehmensweit
Standardmäßig ist ein Webhook persönlich und feuert nur für deine eigenen Aufnahmen. Mit "directoryWide": true feuert er für die Aufnahmen aller Nutzer des Unternehmenskontos. Unternehmensweite Webhooks dürfen nur Admins und Owner anlegen.
Dieselbe Aufteilung gilt, wenn du Webhooks liest und verwaltest:
| Rolle | GET/v1.0/directories/{directoryId}/webhooks zeigt | Darf ändern, pausieren und löschen |
|---|---|---|
| Admin oder Owner | Alle Webhooks des Unternehmenskontos, persönliche und unternehmensweite | Jeden Webhook des Unternehmenskontos |
| Normaler Nutzer | Nur die eigenen persönlichen Webhooks | Nur die eigenen persönlichen Webhooks |
4. Die Zustellung empfangen
Wenn das Ereignis feuert, schickt Sally einen POST-Request mit JSON-Body an deine URL. Die Nutzlast enthält die Zusammenfassung mit ihren Abschnittseinträgen, in derselben Form wie in Zusammenfassungen abrufen. Ein Parser reicht also für beide Wege. Diese Felder nutzen die Beispiel-Empfänger:
| Feld | Typ | Bedeutung |
|---|---|---|
recordingSummaryId | string | Id der Zusammenfassung. Nimm sie als Schlüssel für die idempotente Verarbeitung. |
recordingId | string | Die Aufnahme, zu der die Zusammenfassung gehört. |
appointmentId | string oder null | Der Termin, falls es einen gibt. |
summary | string oder null | Text der Zusammenfassung. |
sectionItems | array | Die strukturierten Ergebnisse je Abschnitt des Meeting-Templates. Einträge mit sectionType tasks tragen subject, responsibleUserName und dueDate. |
Ein minimaler Empfänger antwortet sofort und erledigt die Arbeit danach. Der cURL-Reiter zeigt, wie du deinen Endpunkt lokal mit einer echten Zusammenfassung testest, bevor Sally die erste schickt.
- cURL
- JavaScript
- Python
# Eine echte Zusammenfassung holen und an deinen lokalen Endpunkt schicken, wie Sally es tun würde.
curl -s "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/summaries/$RECORDING_SUMMARY_ID" \
-H "Authorization: Bearer $SALLY_TOKEN" > summary.json
curl -i -X POST "http://localhost:3000/hooks/sally" \
-H "Content-Type: application/json" \
--data @summary.json
// receiver.mjs: npm install express, dann node receiver.mjs
import express from 'express';
const app = express();
app.use(express.json({limit: '10mb'})); // Abschnittseinträge machen den Großteil der Nutzlast aus
const seen = new Set(); // im Betrieb deine Datenbank nutzen
function handle(summary) {
const id = summary.recordingSummaryId;
if (!id || seen.has(id)) return; // wiederholte Zustellung, nichts zu tun
seen.add(id);
const tasks = (summary.sectionItems ?? []).filter((item) => item.sectionType === 'tasks');
console.log(`Zusammenfassung ${id} zur Aufnahme ${summary.recordingId}`);
console.log(summary.summary);
for (const task of tasks) {
console.log(`- ${task.subject} (${task.responsibleUserName ?? 'niemand'}, fällig ${task.dueDate ?? 'offen'})`);
}
}
app.post('/hooks/sally', (req, res) => {
res.sendStatus(200); // zuerst antworten
setImmediate(() => {
try {
handle(req.body);
} catch (error) {
console.error(error);
}
});
});
app.listen(3000, () => console.log('Lauscht auf http://localhost:3000/hooks/sally'));
# receiver.py: pip install flask, dann python receiver.py
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=4)
seen = set() # im Betrieb deine Datenbank nutzen
def handle(summary):
summary_id = summary.get("recordingSummaryId")
if not summary_id or summary_id in seen:
return # wiederholte Zustellung, nichts zu tun
seen.add(summary_id)
tasks = [i for i in summary.get("sectionItems") or [] if i["sectionType"] == "tasks"]
print(f"Zusammenfassung {summary_id} zur Aufnahme {summary['recordingId']}")
print(summary.get("summary"))
for task in tasks:
print(f"- {task['subject']} ({task['responsibleUserName'] or 'niemand'}, fällig {task['dueDate'] or 'offen'})")
@app.post("/hooks/sally")
def sally_hook():
summary = request.get_json(force=True)
executor.submit(handle, summary) # die Arbeit passiert nach der Antwort
return "", 200
if __name__ == "__main__":
app.run(port=3000)
Schreib den rohen Body der ersten echten Zustellung einmal ins Log und vergleich ihn mit deinem Parser. So überrascht dich ein Feld, auf das du dich verlässt, nie erst im Betrieb.
5. Auf deiner Seite
- Antworte schnell mit 2xx und erledige die eigentliche Arbeit danach, damit eine langsame Verarbeitung die Zustellung nicht blockiert.
- Bau deinen Empfänger idempotent, mit der Id der Zusammenfassung (
recordingSummaryId) als Schlüssel, damit eine wiederholte Zustellung keinen Schaden anrichtet. - Die Nutzlast enthält die Zusammenfassung mit ihren Abschnittseinträgen, in derselben Form wie in Zusammenfassungen abrufen. Ein Parser reicht also für beide Wege.
6. Webhook pausieren, ändern oder löschen
POST/v1.0/directories/{directoryId}/webhooks/{webhookId}/deactivate pausiert einen Webhook, ohne ihn zu löschen. Er bleibt in der Liste, feuert aber nicht, bis du ihn mit POST/v1.0/directories/{directoryId}/webhooks/{webhookId}/activate wieder aktivierst. Beide Aufrufe brauchen keinen Request-Body und liefern den geänderten Webhook zurück.
- cURL
- JavaScript
- Python
# Pausieren
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/webhooks/$WEBHOOK_ID/deactivate" \
-H "Authorization: Bearer $SALLY_TOKEN"
# Fortsetzen
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/webhooks/$WEBHOOK_ID/activate" \
-H "Authorization: Bearer $SALLY_TOKEN"
async function setActive(webhookId, active) {
const action = active ? 'activate' : 'deactivate';
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/webhooks/${webhookId}/${action}`,
{method: 'POST', headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
return response.json();
}
await setActive(webhookId, false); // pausieren
await setActive(webhookId, true); // fortsetzen
import os
import requests
def set_active(webhook_id, active):
action = "activate" if active else "deactivate"
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/webhooks/{webhook_id}/{action}",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
return response.json()
set_active(webhook_id, False) # pausieren
set_active(webhook_id, True) # fortsetzen
Nach einem Deaktivieren steht isActive auf false:
{
"webhookId": "af39409b-ca90-443d-a5e8-47dfd97c518f",
"name": "CRM-Sync",
"event": "summary.ready",
"url": "https://example.com/hooks/sally",
"isActive": false,
"directoryWide": false,
"createdOn": "2026-09-24T09:30:00Z"
}
Während du deinen Endpunkt umbaust, deaktiviere den Webhook, statt ihn zu löschen, und aktiviere ihn danach wieder.
Um einen Webhook auf eine neue URL umzustellen, umzubenennen oder sein Ereignis zu wechseln, nimm PATCH/v1.0/directories/{directoryId}/webhooks/{webhookId}. Es ändern sich nur die Felder, die du mitschickst (url, event, name). Aktivieren und Deaktivieren geht dort nicht, sondern über die beiden Aufrufe oben. DELETE/v1.0/directories/{directoryId}/webhooks/{webhookId} entfernt einen Webhook und antwortet mit 204.
7. Komplettes Einrichtungsskript
Dieses Skript sorgt dafür, dass genau ein Webhook auf deine URL zeigt: Es sucht einen vorhandenen Webhook mit dieser URL, aktiviert ihn, falls er pausiert ist, und registriert nur dann einen neuen, wenn es keinen gibt. Du kannst es bei jedem Deployment laufen lassen.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Braucht SALLY_TOKEN, DIRECTORY_ID und HOOK_URL sowie jq.
set -euo pipefail
BASE="https://api.sally.io/v1.0/directories/$DIRECTORY_ID"
existing=$(curl -s "$BASE/webhooks" -H "Authorization: Bearer $SALLY_TOKEN" \
| jq -c --arg url "$HOOK_URL" 'map(select(.url == $url)) | first // empty')
if [ -z "$existing" ]; then
curl -s -X POST "$BASE/webhooks" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"url\": \"$HOOK_URL\", \"event\": \"summary.ready\", \"name\": \"CRM-Sync\"}" | jq .
elif [ "$(echo "$existing" | jq '.isActive')" = "false" ]; then
id=$(echo "$existing" | jq -r '.webhookId')
curl -s -X POST "$BASE/webhooks/$id/activate" -H "Authorization: Bearer $SALLY_TOKEN" | jq .
else
echo "$existing" | jq .
fi
// setup-webhook.mjs, braucht SALLY_TOKEN, DIRECTORY_ID und HOOK_URL. Node 18 oder neuer.
const BASE = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}`;
const HOOK_URL = process.env.HOOK_URL;
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.json();
}
const webhooks = await sally('GET', '/webhooks');
let webhook = webhooks.find((w) => w.url === HOOK_URL);
if (!webhook) {
webhook = await sally('POST', '/webhooks', {url: HOOK_URL, event: 'summary.ready', name: 'CRM-Sync'});
} else if (!webhook.isActive) {
webhook = await sally('POST', `/webhooks/${webhook.webhookId}/activate`);
}
console.log(webhook);
# setup_webhook.py, braucht SALLY_TOKEN, DIRECTORY_ID und HOOK_URL.
import os
import requests
BASE = f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}"
HOOK_URL = os.environ["HOOK_URL"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SALLY_TOKEN']}"
def sally(method, path, body=None):
response = session.request(method, f"{BASE}{path}", json=body)
response.raise_for_status()
return response.json()
webhooks = sally("GET", "/webhooks")
webhook = next((w for w in webhooks if w["url"] == HOOK_URL), None)
if webhook is None:
webhook = sally("POST", "/webhooks", {"url": HOOK_URL, "event": "summary.ready", "name": "CRM-Sync"})
elif not webhook["isActive"]:
webhook = sally("POST", f"/webhooks/{webhook['webhookId']}/activate")
print(webhook)
8. Endpunkte im Überblick
Jeder Eintrag führt zur Referenz, wo du die Anfrage mit deinem eigenen Token senden kannst.
- GET
…/webhooksListet die registrierten Webhooks aufAusprobieren - POST
…/webhooksRegistriert einen Webhook (löst nach der Zusammenfassung aus)Ausprobieren - PATCH
…/webhooks/{webhookId}Aktualisiert einen Webhook (url, event, name)Ausprobieren - POST
…/webhooks/{webhookId}/activateAktiviert einen WebhookAusprobieren - POST
…/webhooks/{webhookId}/deactivateDeaktiviert einen WebhookAusprobieren - DELETE
…/webhooks/{webhookId}Löscht (deaktiviert) einen WebhookAusprobieren
Wie du Webhooks ohne Code direkt in der Sally-App einrichtest, zeigt die Anleitung zur Webhook-Integration.