Waterr AI Logo
EngineeringAugust 5, 2026

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.

Harshit SharmaFounder & CEO, Waterr AI

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.

Waterr for developers — ship the conversation, not the plumbing (1:14) — watch on YouTube

The events

Nine event types. Eight ship today; one is documented as planned.

EventFires when
meeting.createdA meeting row is created via the API
participant.joinedA participant's joined_at goes from null to set
participant.leftA participant's left_at goes from null to set
meeting.endedThe meeting's ended_at is set — before analysis runs
transcript.readyThe full sentenced transcript is persisted
session.analysis_completeThe AI analysis with goal scores is saved
analysis.failedThe analysis pipeline errored
recording.readyThe recording row is created after upload
meeting.startedPlanned — not implemented yet

Every event carries the same envelope:

json
{
  "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:

json
{
  "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

bash
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:

bash
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:

js
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:

python
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:

js
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:

js
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:

  1. meeting.created
  2. participant.joined — once per join
  3. participant.left — once per departure
  4. meeting.ended
  5. transcript.ready
  6. session.analysis_complete, or analysis.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:

js
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:

bash
GET  /v1/webhooks/endpoints/{id}/deliveries    # paginated log
GET  /v1/webhooks/deliveries/{id}              # payload + your response
POST /v1/webhooks/deliveries/{id}/redeliver    # re-queue it

The 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

bash
POST /v1/webhooks/endpoints/:id/rotate-secret

The 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:

bash
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:

bash
GET  /v1/webhooks/endpoints/{id}/deliveries   # find the delivery
POST /v1/webhooks/deliveries/{id}/redeliver   # fire it again

One 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_urlstops 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.

WebhooksAPIEngineeringAI Meetings