# Guide to Meeting BaaS Webhooks: Features and Best Practices

[Meeting BaaS](https://meetingbaas.com) sends recording bots into Zoom, Google Meet and Microsoft Teams calls through an API, and can schedule them from your users' connected calendars. A bot's life is mostly asynchronous: it queues, joins, waits to be admitted, records, leaves, and then processes the transcript and media. Meeting BaaS webhooks report each of those steps, the chat messages posted during the call, and changes in connected calendars, so your app can react without polling bot status.

## What are Meeting BaaS webhooks?

In API v2, webhooks are configured per account. Each endpoint you add in account settings receives every bot and calendar event for every bot, and Meeting BaaS delivers them through Svix.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Success | Only `2xx`. Redirects (3xx), 4xx and 5xx are all retried |
| Timeout | 30 seconds |
| Delivery | At-least-once, generally in order but not guaranteed. Deduplicate on `svix-id` |

Meeting BaaS also has **callbacks**, set per bot with `callback_config` when you create it. They are separate from webhooks: sent directly rather than through Svix, authenticated with a static `x-mb-secret` header instead of a signature, and retried only four times over about 21 seconds. This guide covers account-level webhooks.

## Meeting BaaS webhook features

| Feature | Details |
| --- | --- |
| Configuration | Account settings only, not the API |
| Scope | Every endpoint gets all bot and calendar events |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Secret | Per endpoint. Format not shown in the docs |
| Body | JSON |
| Event routing | Body field `event` |
| Retries | 7 attempts in about 3 hours |
| Resend | `POST /v2/bots/:bot_id/resend-webhook` |
| Source IPs | Not published |

All 12 events are handled in the skill's [example receivers](https://github.com/hookdeck/webhook-skills/tree/main/skills/meeting-baas-webhooks/examples).

## Events and payloads

Bot events and calendar events share a top-level shape:

| Field | Meaning |
| --- | --- |
| `event` | The event name. Branch on this |
| `data` | The event payload |
| `data.bot_id` | The bot (bot events) |
| `data.event_id` | Bot events carry an `event_id` in `data` |
| `data.extra` | Echoes the `extra` object you set when creating the bot |
| `extra` (top level) | Where `bot.chat_message` puts it: beside `data`, not inside |

Calendar payloads are keyed by `data.calendar_id`. Calendar event payloads also have a `data.event_id` inside each instance, which identifies the calendar event, not the webhook.

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `bot.status_change` | A bot's status changes. `data.status.code` is the new status, with `created_at`, `start_time` (only on `in_call_recording`) and `error_message` on failures | Track bot progress, update UI |
| `bot.completed` | A bot finished recording and processing. Carries `transcription`, `mp4`, `audio`, `diarization`, `chat_messages` URLs, `duration_seconds`, `participants`, `speakers` | Download recordings, post-meeting workflows |
| `bot.chat_message` | A participant posts in the meeting chat. `message_id`, `sender_name`, `sender_id` (may be null), `text`, `sent_at` (when the webhook was dispatched) | Chat integrations, compliance logging |
| `bot.failed` | A bot failed. `error_code` (e.g. `BOT_NOT_ACCEPTED`, `TIMEOUT_WAITING_TO_START`, `INSUFFICIENT_TOKENS`, `DAILY_BOT_CAP_REACHED`, `INVALID_MEETING_PLATFORM`, `TRANSCRIPTION_ERROR`) and `error_message` | Error handling, retry bot creation |
| `calendar.connection_created` | A calendar connection was created (`platform`, `account_email`, `calendar_name`) | Confirm the integration |
| `calendar.connection_updated` | A connection was updated, e.g. credentials refreshed (`status`) | Monitor connection health |
| `calendar.connection_deleted` | A connection was deleted | Clean up calendar data |
| `calendar.connection_error` | A connection hit an error, e.g. OAuth refresh failed (`error`, `status`) | Alert the user, reconnect |
| `calendar.events_synced` | A calendar sync finished, when it syncs for the first time (`events_synced`, `sync_type`) | Process newly synced events |
| `calendar.event_created` | A new event in a connected calendar (`event_type`, `series_id`, `series_bot_scheduled`, `instances`) | Schedule bots for new events |
| `calendar.event_updated` | An event changed (`affected_instances`) | Update bot schedules |
| `calendar.event_cancelled` | An event was cancelled (`cancelled_instances`) | Cancel scheduled bots |

The three `calendar.event_*` examples and `bot.completed` are abbreviated in Meeting BaaS's docs, so check real payloads before you rely on their nested fields.

`data.status.code` on `bot.status_change` can be any of `queued`, `pickup_delayed`, `transcribing`, `completed`, `failed`, `joining_call`, `in_waiting_room`, `in_waiting_for_host` (Zoom only), `in_call_not_recording`, `in_call_recording`, `recording_paused`, `recording_resumed`, `call_ended`, `recording_succeeded`, `recording_failed`, and the Google Meet and Teams signals `api_request_stop`, `bot_rejected`, `bot_removed`, `bot_removed_too_early`, `waiting_room_timeout`, `invalid_meeting_url` and `meeting_error`. If you only need outcomes, use `bot.completed` and `bot.failed`.

This is the `bot.chat_message` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/meeting-baas/latest/bot.chat_message.json). `extra`, your own metadata from bot creation, sits beside `data` here rather than inside it:

```json
{
  "event": "bot.chat_message",
  "data": {
    "bot_id": "123e4567-e89b-12d3-a456-426614174000",
    "event_id": "789e0123-e45b-67c8-d901-234567890abc",
    "message_id": "spaces/XxM_aNTpzGkB/messages/1773539019302815",
    "sender_name": "John Doe",
    "sender_id": 2,
    "text": "Can we discuss the Q4 roadmap?",
    "sent_at": "2025-01-15T10:32:15Z"
  },
  "extra": {
    "customer_id": "12345"
  }
}

```

`bot.chat_message` covers participants' messages only. The bot's own messages are in the `chat_messages` file linked from `bot.completed`.

## Setting up Meeting BaaS webhooks

1. Open your Meeting BaaS account settings and add a webhook endpoint with your HTTPS URL, for example `https://example.com/webhooks/meeting-baas`.
2. Copy the endpoint's signing secret into `MEETING_BAAS_WEBHOOK_SECRET`.
3. There's no per-event subscription, so your handler must return `2xx` for events it ignores.

Meeting BaaS's docs never show a secret value. They say v2 webhooks go through Svix and should be verified with Svix's libraries, and Svix endpoint secrets look like `whsec_<base64>`. Store the value exactly as copied. If it doesn't start with `whsec_`, see the skill's [verification reference](https://github.com/hookdeck/webhook-skills/blob/main/skills/meeting-baas-webhooks/references/verification.md) for the key derivation to adapt.

To receive Meeting BaaS through Hookdeck, create a source with the **Meeting BaaS** type, paste the secret, and add the source URL as the endpoint. Then forward it locally with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 meeting-baas --path /webhooks/meeting-baas

```

Send a bot into a test meeting and watch the `bot.status_change` events arrive as it joins and records.

> Only Meeting BaaS v2 account-level webhooks are supported. Per-bot callbacks and v1 API webhooks aren't signed this way and fail verification.

## Securing Meeting BaaS webhooks

The check is Svix's: HMAC-SHA256 over `<svix-id>.<svix-timestamp>.<raw body>` with the base64-decoded part of the secret after `whsec_`, compared against each `v1,` entry in `svix-signature`. This function falls back to `webhook-*` header names, as the Hookdeck source does:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Meeting BaaS sets no timestamp tolerance; the Svix SDK applies five minutes. Hookdeck applies none and replays or retries with the original `svix-timestamp`, so behind Hookdeck the SDK's window would reject real events. The verifier above checks only the signature.

`bot.completed` links expire after four hours, so the receiver stores the event and a worker downloads the files straight away:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// botEvents.put(messageId, event) saves the event and resolves once it is durable.
function createMeetingBaasReceiver(botEvents) {
  const app = express();
  app.post('/webhooks/meeting-baas', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.MEETING_BAAS_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await botEvents.put(req.headers['svix-id'] || req.headers['webhook-id'], event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200); // ignored events must still get 2xx
  });
  return app;
}

```

When Meeting BaaS posts to a Hookdeck source, Hookdeck answers `200` even if the signature fails, records the request as failed verification and stops it there.

> **Make Meeting BaaS webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each bot and calendar event, routes recordings to your media pipeline and keeps a replayable history.

## Meeting BaaS webhook limitations and pain points

### Recording links expire in four hours

**The Problem:** A `bot.completed` event processed late has dead links to the transcript and video.

**Why It Happens:** The presigned URLs are valid for four hours. Meeting BaaS keeps its own retries, about three hours in total, inside that window, but a backlog on your side can still outlast it.

**Workarounds:**

* Download artefacts as the first step after receiving `bot.completed`.
* Watch the age of `bot.completed` events in your queue and alert well before four hours.

**How Hookdeck Can Help:** Hookdeck can route `bot.completed` to a dedicated download worker with [filters](/docs/filters), so it never waits behind status updates.

### Three hours of retries, then nothing

**The Problem:** Events from a long outage are gone.

**Why It Happens:** Meeting BaaS makes seven attempts over about three hours. If an endpoint fails every delivery for five days it's disabled, and later messages are dropped silently until you re-enable it.

**Workarounds:**

* Set up a "Webhook Delivery Exhausted" alert rule (`webhook_delivery_exhausted`).
* Resend individual bots' webhooks with the resend endpoint:

| You want to | Use |
| --- | --- |
| Resend a bot's webhook | `POST /v2/bots/:bot_id/resend-webhook` |
| Know when retries are exhausted | Configure a "Webhook Delivery Exhausted" alert rule (`webhook_delivery_exhausted`) |
| Re-enable a disabled endpoint | Dashboard. Endpoints failing every delivery for 5 days are disabled and later messages are silently dropped |

**How Hookdeck Can Help:** Hookdeck accepts each event as it's sent and [retries](/docs/retries) your service for as long as you choose, so Meeting BaaS's own retry window stops mattering.

### Every endpoint gets every event

**The Problem:** A service that only wants calendar events still receives every bot status change.

**Why It Happens:** Endpoints have no event filter. All bot and calendar events go to all endpoints.

**Workarounds:**

* Return `200` early for events a service doesn't handle.

**How Hookdeck Can Help:** One Hookdeck source can fan events out to several [destinations](/docs/destinations), filtered on `event`.

### Events can arrive out of order

**The Problem:** `bot.completed` is processed before the final `bot.status_change`.

**Why It Happens:** Delivery is generally in order, but retries can reorder events.

**Workarounds:**

* Treat `bot.completed` and `bot.failed` as terminal, and ignore later non-terminal statuses for the same `bot_id`.

**How Hookdeck Can Help:** Hookdeck's event log shows each bot's events in arrival order, which makes ordering problems easy to trace.

## Best practices

### Deduplicate on `svix-id`

Delivery is at least once. Store `svix-id` and skip repeats; [implementing webhook idempotency](/webhooks/guides/implement-webhook-idempotency) walks through the options.

### Use `extra` to link bots to your records

Pass your own IDs in `extra` when you create a bot. They come back in `data.extra`, or at the top level on `bot.chat_message`.

### Handle `bot.failed` by error code

`INSUFFICIENT_TOKENS` and `DAILY_BOT_CAP_REACHED` point at your account's limits, while `BOT_NOT_ACCEPTED` and `TIMEOUT_WAITING_TO_START` come from the meeting itself. Decide per code whether creating a new bot makes sense.

### Keep chat out of logs

`bot.chat_message` carries what participants typed. Log the `bot_id` and `message_id`, not the text.

## Conclusion

Meeting BaaS v2 webhooks report each bot and connected calendar in detail. Recording links last four hours, so download them as soon as the event arrives.

[Hookdeck Event Gateway](https://hookdeck.com) gives each Meeting BaaS event a longer retry window and routes recordings, chat and calendar changes where they belong. [Get a free Hookdeck account](https://dashboard.hookdeck.com/signup).