A call ends, and a brief lands in your own system a second later. No polling, no waiting. Sayli sends the event to an endpoint you own, and your tools pick it up from there.
Webhooks are on the Team plan and up.
Events, pushed to you
You give Sayli an HTTPS endpoint. When something happens on a call, Sayli sends a signed POST to that endpoint. You decide what to do with it: file a ticket, post to a channel, update a record.
Endpoints must be public HTTPS URLs. Sayli checks the address before sending and will not deliver to internal or private network addresses.
What we emit
Two events are live today.
More events are planned, including meeting.started, meeting.ended, and action_item.created. They are not sent yet. Subscribing to them today does nothing until they ship, and shipped events are announced in the changelog.
The delivery
Every delivery is a POST with Content-Type: application/json and a sayli-webhook/1.0 user agent, plus these headers:
brief.generated.The body always has the same three fields: the event name, when it happened, and the event's data.
{
"event": "brief.generated",
"occurred_at": "2026-06-29T15:42:10.482910+00:00",
"data": {
"meeting_id": "0d9e07a2-5b1f-4c8e-9f3a-2f6d1c8b4a70",
"title": "Weekly pipeline review",
"headline": "Renewal is on track, security review is the open risk",
"summary": "…",
"action_items": [
{
"text": "Send the SOC 2 report to Dana",
"owner": "You",
"due": "end of week",
"due_date": "2026-07-03",
"confidence": "high"
}
],
"confidence": "high"
}
}Two shapes are worth reading twice, because guessing them wrong is the usual
integration bug. Action items are objects, never strings: each carries
text, owner, due as the phrase that was said, due_date as a real
YYYY-MM-DD or empty, and its own confidence. Confidence is a string,
one of high, medium or low, on both the brief and each item; anything
else is coerced to medium before the brief is saved, so you will never see a
number.
Verify the signature
The signature covers the timestamp and the raw body, joined by a dot: HMAC-SHA256(secret, "{Sayli-Timestamp}.{body}"), hex-encoded, in the Sayli-Signature header. Signing the timestamp lets you reject replays, not just tampering.
To verify a delivery:
- Read the raw request body before parsing any JSON.
- Compute the HMAC over
timestamp + "." + bodywith your endpoint's secret. - Compare against
Sayli-Signaturewith a constant-time comparison. - Reject the delivery if the timestamp is older than your replay window.
import hashlib
import hmac
import time
def verify(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers["Sayli-Timestamp"]
signature = headers["Sayli-Signature"]
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
return abs(time.time() - int(timestamp)) <= 300 # 5 minute replay windowTreat a missing or wrong signature as a request you did not send. Do not process the body.
When deliveries fail
A delivery gets up to three attempts, with a backoff that starts at half a second and triples. Sayli retries a connection error or a 5xx. It does not retry a 4xx, because a rejected body will be rejected again.
So build your endpoint to expect duplicates. A slow or flapping receiver that eventually succeeds may have already processed the first attempt. Sayli-Delivery-Id is stable across the retries of one delivery, so key your idempotency on it. Sayli waits up to 8 seconds per attempt.
After 10 failures in a row, Sayli disables the endpoint automatically so events stop piling up against a dead URL. Re-enable it with the API, by patching the endpoint back to active: there is no webhook screen in the desktop app today, so the API is the way in and out. Anything a webhook tells you can also be read back from the API, so a missed brief.generated is recoverable by fetching the brief.
Respond with a 2xx as soon as you receive the event, then do the slow work in the background. A response that beats the 8 second window is a delivery that counts.