AI Meeting Webhooks: Events, Signatures, and the Retry Ladder
Nine events, one signature scheme, and a five-step backoff ladder. Everything you need to wire an AI meeting API into your own pipeline without polling, plus the three mistakes that break integrations in production.
Polling an AI meeting to find out whether it finished is the wrong shape. The meeting takes as long as it takes, the transcript lands a moment later, the analysis takes a few minutes after that, and the recording arrives whenever the upload finishes. Four different clocks. If you poll, you either hammer the API or you find out late.
Webhooks fix that, and they're the part of any meeting API that people wire up last and get wrong first. Here's the complete surface, the two verification implementations, and the three things that actually break in production.
The events
Nine event types. Eight ship today; one is documented as planned.
| Event | Fires when |
|---|---|
meeting.created | A meeting row is created via the API |
participant.joined | A participant's joined_at goes from null to set |
participant.left | A participant's left_at goes from null to set |
meeting.ended | The meeting's ended_at is set — before analysis runs |
transcript.ready | The full sentenced transcript is persisted |
session.analysis_complete | The AI analysis with goal scores is saved |
analysis.failed | The analysis pipeline errored |
recording.ready | The recording row is created after upload |
meeting.started | Planned — not implemented yet |
Every event carries the same envelope:
{
"id": "9b6c…",
"event": "session.analysis_complete",
"created_at": "2026-06-27T09:25:00.000Z",
"data": {
"meeting_id": "meeting-uuid",
"scenario_id": "scenario-uuid"
}
}data gains fields per event. meeting.ended adds ended_at and duration_seconds. transcript.ready adds transcript_id and sentence_count. The participant events nest a full participant object with id, email, name, role, source, joined_at, left_at, and is_self.
The one worth reading closely is session.analysis_complete, because it's the only event that carries a real payload rather than a pointer:
{
"event": "session.analysis_complete",
"data": {
"meeting_id": "meeting-uuid",
"scenario_id": "scenario-uuid",
"analysis": {
"analysis_id": "analysis-uuid",
"average_score": 82,
"total_score": 412,
"summary": "…",
"strengths": "…",
"growth_areas": "…",
"recommendations": "…",
"goal_results": [
{ "goal": "Problem Solving", "result": { "score": 4, "feedback": "…" } }
],
"highlights": [
{ "title": "Identified the bottleneck early", "timestamp": 412 }
],
"conversation_length": 187
}
}
}That goal_results array is the thing most teams are integrating for. It's the human on the call scored against the goals you defined on the scenario, with written feedback per goal. You don't need a follow-up request to get it.
The other two are pointers. transcript.ready gives you a transcript_id; fetch the sentences from GET /v1/transcripts/meeting/{meeting_id}. recording.ready gives you a recording_id; get a signed playback URL from GET /v1/recordings/url-with-thumbnail/{meeting_id}.
Creating an endpoint
curl -X POST https://api.waterr.ai/v1/webhooks/endpoints \
-H "Authorization: Bearer wai_<your_key>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/waterr",
"description": "prod CRM sync",
"subscribed_events": ["*"]
}'The response contains a signing_secret starting whsec_. It is shown once. There is no endpoint that gives it back to you — if you lose it, you rotate. Put it in your secret manager in the same breath as you create the endpoint, not after you've finished testing.
Narrow the subscription once you know what you actually consume:
curl -X PATCH https://api.waterr.ai/v1/webhooks/endpoints/{id} \
-H "Authorization: Bearer wai_<your_key>" \
-H "Content-Type: application/json" \
-d '{ "subscribed_events": ["session.analysis_complete", "meeting.ended"] }'Endpoints have an enabled flag, so you can pause one during an incident without deleting it and losing the delivery history. Each endpoint retries independently and keeps its own audit trail.
Verifying the signature
Every delivery carries:
Waterr-Signature: t=1751025600,v1=4c1f8a…t is Unix epoch seconds. v1 is HMAC-SHA256 of ` ${t}.${rawBody} ` using your signing secret. Check the timestamp is fresh — five minutes is the recommended tolerance — then compare the HMAC in constant time.
Node, on Express:
const crypto = require('crypto');
const TOLERANCE = 5 * 60;
const SECRET = process.env.WATERR_WEBHOOK_SECRET;
function verify(rawBody, header) {
if (!header) return false;
// Collect every v1 value — during a secret rotation there are two.
const parts = header.split(',').map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false;
const signatures = parts
.filter((p) => p.startsWith('v1='))
.map((p) => p.slice(3));
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected);
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig);
// timingSafeEqual throws on length mismatch — check first.
return (
sigBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(sigBuf, expectedBuf)
);
});
}Python, on Flask:
import hmac, hashlib, os, time
SECRET = os.environ["WATERR_WEBHOOK_SECRET"]
TOLERANCE = 5 * 60
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = [p.strip() for p in header.split(",")]
ts = next((p[2:] for p in parts if p.startswith("t=")), None)
if ts is None:
return False
try:
t = int(ts)
except ValueError:
return False
if abs(time.time() - t) > TOLERANCE:
return False
expected = hmac.new(
SECRET.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return any(
hmac.compare_digest(p[3:], expected)
for p in parts
if p.startswith("v1=")
)Two details in there are worth calling out, because both are things you only discover after they've bitten you.
**Parse for multiple v1 values.** During a secret rotation the header carries two of them and you accept if either matches. If you parse the header into a map — splitting on commas and then on = — the second v1 silently overwrites the first, and half your deliveries fail for the 24 hours the old secret is still live. Collect them into a list.
**timingSafeEqual throws on a length mismatch.** It doesn't return false; it raises. If a malformed signature ever reaches it, an unguarded call takes your handler down with a 500, which the sender reads as a retryable failure. Compare lengths first.
Use the raw body
The HMAC is computed over the exact bytes that were sent. If your framework parses JSON before your handler sees it and you re-serialize to verify, key order and whitespace shift and every signature fails.
On Express, capture it during parsing:
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);On Flask, request.get_data() before touching request.json. This is the single most common webhook integration bug in any API, not just ours.
Idempotency
The envelope id is stable across retries. Use it as your dedupe key, and store before you process:
const seen = await db.webhookEvents.findUnique({ where: { id: event.id } });
if (seen) return res.sendStatus(200);
await db.webhookEvents.create({ data: { id: event.id, event: event.event } });Return 200 for duplicates. A retry that arrives because your first 200 got lost on the wire is not an error condition, and treating it as one is how you end up with two CRM records for one meeting.
Ordering, and the one exception
For a single meeting, the sequence is guaranteed:
meeting.createdparticipant.joined— once per joinparticipant.left— once per departuremeeting.endedtranscript.readysession.analysis_complete, oranalysis.failed
recording.ready is the exception. It fires independently, because the recording upload is asynchronous and finishes on its own schedule. It is not guaranteed to arrive after the analysis.
So don't gate your analysis handler on the recording being there. If your pipeline needs both — say, a scorecard with a video clip attached — treat them as two independent arrivals that converge, not as a sequence:
async function onEither(meetingId) {
const row = await db.meetings.findUnique({ where: { id: meetingId } });
if (row.analysisId && row.recordingId) await publishScorecard(row);
}Write whichever lands first, check for the other, and publish when both are present.
The retry ladder
Ten seconds per attempt. Any 2xx is success.
A 4xx that isn't 408 or 429 is treated as a permanent rejection — the event is marked failed and never retried. That matters more than it sounds: if your handler returns 400 on an unrecognised event type, you have silently opted out of that event forever. Return 200 for events you don't handle yet.
For 5xx, 408, 429, and network errors, the backoff runs:
30s → 5m → 30m → 2h → 12h, then the delivery is marked dead.
That's a shade under 15 hours of tolerance for an endpoint that's down. Every attempt is logged, and you can go read it:
GET /v1/webhooks/endpoints/{id}/deliveries # paginated log
GET /v1/webhooks/deliveries/{id} # payload + your response
POST /v1/webhooks/deliveries/{id}/redeliver # re-queue itThe redeliver endpoint is the recovery path after a bad deploy. You don't need to reconstruct events from your own logs — replay them.
Rotating a secret
POST /v1/webhooks/endpoints/:id/rotate-secretThe previous secret stays valid for 24 hours. During that window, deliveries are signed with both:
Waterr-Signature: t=1751025600,v1=4c1f8a…,v1=78d4b2…Accept if either matches — which is why the verifier above collects all of them. Deploy the new secret inside the window, confirm deliveries are landing, and you're done with no dropped events.
Testing locally
Point a tunnel at your dev server and register it as a second endpoint:
cloudflared tunnel --url http://localhost:3000
# or: ngrok http 3000
curl -X POST https://api.waterr.ai/v1/webhooks/endpoints \
-H "Authorization: Bearer wai_<your_key>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://<your-tunnel>.trycloudflare.com/webhooks/waterr",
"description": "local dev",
"subscribed_events": ["session.analysis_complete", "meeting.ended"]
}'Keep it as its own endpoint rather than repointing production. Endpoints retry independently, so a laptop that goes to sleep only affects the local one.
From there, the delivery log is your test loop. Run one real meeting, then replay its events at your handler as many times as you need:
GET /v1/webhooks/endpoints/{id}/deliveries # find the delivery
POST /v1/webhooks/deliveries/{id}/redeliver # fire it againOne meeting gives you a reusable fixture for every branch of your handler, and GET /v1/webhooks/deliveries/{id} shows both the payload we sent and the response you returned — which is usually enough to find the bug without adding logging.
If you're on the legacy per-scenario webhook
The old per-scenario hook — configured through PUT /v1/scenarios/{id}/session-options with a webhook_url — stops working on 25 September 2026.
Until then both paths fire, so you'll see the new Waterr-Signature header alongside the legacy X-Waterr-Signature: sha256=<hex>, which is a body-only HMAC with no replay protection. Migration is three steps: create a v2 endpoint pointing at the same URL, update your verifier to the new format, then remove webhook_url from the scenario's session options.
Do it now rather than in September. The v2 header is replay-protected, the delivery log alone is worth the change, and the per-account model means you configure once instead of per scenario.
Frequently asked questions
Which event means the meeting is done?
meeting.ended means the call is over. session.analysis_complete means the results exist. If you're triggering downstream work off scores, wait for the second one — meeting.ended fires before analysis runs.
How do I test webhooks without running a real meeting?
Run one real meeting against a tunnelled dev endpoint, then replay its deliveries with POST /v1/webhooks/deliveries/{id}/redeliver as often as you need. One meeting becomes a permanent fixture for every branch of your handler.
What happens if my endpoint is down for an hour? You keep the events. The backoff ladder runs 30s → 5m → 30m → 2h → 12h before a delivery is marked dead, so an hour of downtime is comfortably inside the window. Anything that did die can be replayed from the delivery log.
Are webhooks per-account or per-scenario? Per-account in v2. You create endpoints against your account and subscribe them to the events you want. The old per-scenario configuration is deprecated and ends 25 September 2026.
Why is my signature verification failing?
Almost always the raw body. Capture the bytes before JSON parsing. After that, check you're handling two v1 values during a rotation, and that you're comparing buffer lengths before calling timingSafeEqual.
If you're building the integration rather than reading about it, the webhooks reference has the full endpoint list, and the API quickstart gets you to a first meeting in two calls.
