How to receive events with webhooks
Instead of asking every few minutes whether a meeting is done, let Sally tell you. A webhook sends a POST request to your URL as soon as a summary is ready. That is the difference between an integration that feels instant and one that feels like a nightly job.
This guide registers a webhook, builds a small receiving endpoint in Node and Python, and shows how to pause, change and remove webhooks. Every example works as it stands once SALLY_TOKEN and DIRECTORY_ID are set, see the Quickstart. It helps to know the summary shape from Get meeting summaries.
Quick navigation
- Register a webhook
- The events
- Personal or company-wide
- Receive the delivery
- On your side
- Pause, change or remove a webhook
- Complete setup script
- Endpoints at a glance
1. Register a webhook
POST/v1.0/directories/{directoryId}/webhooks registers a webhook. The body takes these fields:
| Field | Type | Default | Description |
|---|---|---|---|
url | string | Required. The endpoint Sally sends the POST request to. | |
event | string | summary.ready | When the webhook fires: summary.ready or manual, see The events. |
name | string | A label to recognize the webhook in the list. | |
directoryWide | boolean | false | false fires only for your own recordings, true for the recordings of all users. See Personal or company-wide. |
- 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()
The answer is the new 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"
}
| Field | Type | Meaning |
|---|---|---|
webhookId | string | Id of the webhook. Keep it to pause, change or remove the webhook later. |
name | string | Display name of the webhook. |
event | string | When the webhook fires. |
url | string | Target URL that Sally sends the event to. |
isActive | boolean | Whether the webhook is actively firing. |
directoryWide | boolean | true for all users of the company account, false for your own recordings only. |
createdOn | string | Creation timestamp (ISO 8601). |
2. The events
summary.readyfires after every summary. This is the default and usually the one you want.manualnever fires on its own. A person starts it from a meeting or a recording, which is useful when someone should decide what gets sent.
Webhooks created before the current model may still report the older events recording.summarized or meeting.summarized. They stay readable but can no longer be set.
3. Personal or company-wide
By default a webhook is personal and fires only for your own recordings. With "directoryWide": true it fires for the recordings of all users of the company account. Only admins and owners can create company-wide webhooks.
The same split applies when you read and manage webhooks:
| Role | GET/v1.0/directories/{directoryId}/webhooks shows | May change, pause and remove |
|---|---|---|
| Admin or owner | All webhooks of the company account, personal and company-wide | Any webhook of the company account |
| Regular user | Only their own personal webhooks | Only their own personal webhooks |
4. Receive the delivery
When the event fires, Sally sends a POST request with a JSON body to your URL. The payload contains the summary with its section items, in the same shape as in Get meeting summaries, so one parser serves both paths. These are the fields the example handlers use:
| Field | Type | Meaning |
|---|---|---|
recordingSummaryId | string | Id of the summary. Use it as the key for idempotent handling. |
recordingId | string | The recording the summary belongs to. |
appointmentId | string or null | The appointment, if there is one. |
summary | string or null | Summary text. |
sectionItems | array | The structured results per section of the meeting template. Items with sectionType tasks carry subject, responsibleUserName and dueDate. |
A minimal endpoint answers right away and does the work afterwards. The cURL tab shows how to test your endpoint locally with a real summary before Sally sends the first one.
- cURL
- JavaScript
- Python
# Fetch a real summary and send it to your local endpoint, like Sally would.
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, then node receiver.mjs
import express from 'express';
const app = express();
app.use(express.json({limit: '10mb'})); // section items make up most of the payload
const seen = new Set(); // use your database in production
function handle(summary) {
const id = summary.recordingSummaryId;
if (!id || seen.has(id)) return; // repeated delivery, nothing to do
seen.add(id);
const tasks = (summary.sectionItems ?? []).filter((item) => item.sectionType === 'tasks');
console.log(`Summary ${id} for recording ${summary.recordingId}`);
console.log(summary.summary);
for (const task of tasks) {
console.log(`- ${task.subject} (${task.responsibleUserName ?? 'unassigned'}, due ${task.dueDate ?? 'n/a'})`);
}
}
app.post('/hooks/sally', (req, res) => {
res.sendStatus(200); // answer first
setImmediate(() => {
try {
handle(req.body);
} catch (error) {
console.error(error);
}
});
});
app.listen(3000, () => console.log('Listening on http://localhost:3000/hooks/sally'));
# receiver.py: pip install flask, then python receiver.py
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=4)
seen = set() # use your database in production
def handle(summary):
summary_id = summary.get("recordingSummaryId")
if not summary_id or summary_id in seen:
return # repeated delivery, nothing to do
seen.add(summary_id)
tasks = [i for i in summary.get("sectionItems") or [] if i["sectionType"] == "tasks"]
print(f"Summary {summary_id} for recording {summary['recordingId']}")
print(summary.get("summary"))
for task in tasks:
print(f"- {task['subject']} ({task['responsibleUserName'] or 'unassigned'}, due {task['dueDate'] or 'n/a'})")
@app.post("/hooks/sally")
def sally_hook():
summary = request.get_json(force=True)
executor.submit(handle, summary) # work happens after the answer
return "", 200
if __name__ == "__main__":
app.run(port=3000)
Log the raw body of the first real delivery once and compare it with your parser. That way a field you rely on never surprises you in production.
5. On your side
- Answer quickly with 2xx and do the actual work afterwards, so slow processing does not block the delivery.
- Make your handler idempotent, keyed on the summary id (
recordingSummaryId), so a repeated delivery does no harm. - The payload contains the summary with its section items, in the same shape as in Get meeting summaries. One parser serves both paths.
6. Pause, change or remove a webhook
POST/v1.0/directories/{directoryId}/webhooks/{webhookId}/deactivate pauses a webhook without deleting it. It stays in the list but does not fire until you activate it again with POST/v1.0/directories/{directoryId}/webhooks/{webhookId}/activate. Both calls take no request body and return the updated webhook.
- cURL
- JavaScript
- Python
# Pause
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/webhooks/$WEBHOOK_ID/deactivate" \
-H "Authorization: Bearer $SALLY_TOKEN"
# Resume
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); // pause
await setActive(webhookId, true); // resume
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) # pause
set_active(webhook_id, True) # resume
After a deactivate, isActive is 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"
}
While you rebuild your endpoint, deactivate the webhook instead of deleting it and activate it again afterwards.
To move a webhook to a new URL, rename it or switch its event, use PATCH/v1.0/directories/{directoryId}/webhooks/{webhookId}. Only the fields you send (url, event, name) change. Activating and deactivating is not done there but with the two calls above. DELETE/v1.0/directories/{directoryId}/webhooks/{webhookId} removes a webhook and answers 204.
7. Complete setup script
This script makes sure exactly one webhook points at your URL: it looks for an existing webhook with that URL, activates it if it is paused, and registers a new one only if none exists. You can run it on every deployment.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs SALLY_TOKEN, DIRECTORY_ID and HOOK_URL, plus 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, needs SALLY_TOKEN, DIRECTORY_ID and HOOK_URL. Node 18 or newer.
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, needs SALLY_TOKEN, DIRECTORY_ID and 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. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
- GET
…/webhooksLists the registered webhooksTry it - POST
…/webhooksRegisters a webhook (fires after the summary)Try it - PATCH
…/webhooks/{webhookId}Updates a webhook (url, event, name)Try it - POST
…/webhooks/{webhookId}/activateActivates a webhookTry it - POST
…/webhooks/{webhookId}/deactivateDeactivates a webhookTry it - DELETE
…/webhooks/{webhookId}Deletes (deactivates) a webhookTry it
For setting up webhooks in the Sally app without code, see the webhook integration guide.