Guide to Meeting BaaS Webhooks: Features and Best Practices
Meeting BaaS 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_ |
| Source IPs | Not published |
All 12 events are handled in the skill's example receivers.
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_ | A calendar connection was created (platform, account_email, calendar_name) | Confirm the integration |
calendar.connection_ | A connection was updated, e.g. credentials refreshed (status) | Monitor connection health |
calendar.connection_ | A connection was deleted | Clean up calendar data |
calendar.connection_ | 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. extra, your own metadata from bot creation, sits beside data here rather than inside it:
{
"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
- Open your Meeting BaaS account settings and add a webhook endpoint with your HTTPS URL, for example
https://example.com/webhooks/meeting-baas. - Copy the endpoint's signing secret into
MEETING_.BAAS_ WEBHOOK_ SECRET - There's no per-event subscription, so your handler must return
2xxfor 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 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:
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:
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:
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 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.completedevents 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, 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_ |
| Know when retries are exhausted | Configure a "Webhook Delivery Exhausted" alert rule (webhook_) |
| 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 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
200early for events a service doesn't handle.
How Hookdeck Can Help: One Hookdeck source can fan events out to several 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.completedandbot.failedas terminal, and ignore later non-terminal statuses for the samebot_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 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 gives each Meeting BaaS event a longer retry window and routes recordings, chat and calendar changes where they belong. Get a free Hookdeck account.