Meeting Bot Webhooks: Event-Driven Architecture Guide

Your bot joined the meeting, recorded it, and left. Now you have no idea what happened. The database shows status: pending. The transcript never arrived. You check your logs and see the webhook endpoint returned a 500 because your database connection was slow. MeetStream didn't retry. The data is gone.

This scenario plays out constantly for teams that treat webhooks as an afterthought, something to wire up quickly and revisit later. But meeting bot webhooks are the entire async state machine for a bot session. Every status transition, every audio file, every transcript arrives via webhook. If your handler is unreliable, your entire product is unreliable.

The specific failure mode above, non-2xx response causing lost data, is a property of how MeetStream's webhook delivery works: unlike some platforms, MeetStream webhooks are not retried on failure. This means your handler must be fast and reliably return 200. The right pattern is to accept the event, validate it, write it to a queue, return 200 immediately, and process asynchronously. This is not optional architecture, it's the only architecture that works at scale.

In this guide, we'll cover the complete event lifecycle with payload examples, HMAC-SHA256 signature verification, idempotency patterns for deduplication, queue-backed handler design, and a complete Node.js/Express webhook handler you can adapt directly. Let's get into it.

The Meeting Bot Event Lifecycle

A bot session produces events in a predictable sequence. Understanding the full lifecycle lets you build a state machine that handles every transition correctly.

bot.joining
    │
    ▼
bot.inmeeting
    │
    ▼
bot.stopped
    │
    ├── audio.processed (if recording enabled)
    ├── transcription.processed (if transcription enabled)
    └── video.processed (if video recording enabled)
Webhook architecture diagram for meeting bots
Webhook architecture for event-driven meeting bot subscriptions. Source: Nordic APIs.

Each event carries a bot_id that links it to the original create_bot request. The bot.stopped event includes a bot_status field with one of: Stopped (normal exit), NotAllowed (platform rejected the bot), Denied (host explicitly rejected), or Error (unexpected failure). The audio.processed, transcription.processed, and video.processed events fire after the meeting ends and MeetStream finishes processing the recorded data.

Example bot.inmeeting payload:

{
  "event": "bot.inmeeting",
  "bot_id": "b_abc123def456",
  "meeting_url": "https://meet.google.com/abc-defg-hij",
  "bot_name": "Notetaker",
  "timestamp": "2025-06-15T15:00:32Z",
  "participants": [
    {"speaker_id": "spk_001", "name": "Alice"},
    {"speaker_id": "spk_002", "name": "Bob"}
  ]
}

Example bot.stopped payload:

{
  "event": "bot.stopped",
  "bot_id": "b_abc123def456",
  "bot_status": "Stopped",
  "meeting_duration_seconds": 2847,
  "timestamp": "2025-06-15T15:48:01Z"
}

Example transcription.processed payload:

{
  "event": "transcription.processed",
  "bot_id": "b_abc123def456",
  "transcript_url": "https://api.meetstream.ai/api/v1/bots/b_abc123def456/transcript",
  "transcript_format": "json",
  "word_count": 4821,
  "timestamp": "2025-06-15T15:52:18Z"
}
How webhooks power event-driven architecture
How webhooks power real-time event-driven systems. Source: HyScaler.

Webhook Signature Verification

Any endpoint that accepts webhooks is a potential attack surface. Without signature verification, anyone who discovers your webhook URL can send fabricated events, injecting fake transcripts, triggering false bot.stopped events, or flooding your handler with noise.

MeetStream signs each webhook delivery with an X-MeetStream-Signature header containing sha256=HMAC-SHA256(raw_body, webhook_secret). Verify it before processing any event:

const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)  // rawBody must be the unparsed bytes
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Two critical implementation notes:

  1. Compute the HMAC over the raw request body bytes, not the parsed JSON object. JSON serialization is not deterministic, whitespace differences between your parsed-and-restringified payload and the original will cause valid signatures to fail verification.
  2. Use timingSafeEqual rather than string equality. String equality (===) short-circuits on the first mismatched character, which leaks timing information that can be exploited to forge signatures. timingSafeEqual always takes the same time regardless of where the strings diverge.

The Queue-Backed Handler Pattern

As noted: MeetStream webhooks are not retried on non-2xx responses. This single fact should drive your entire handler architecture. The handler must return 200 reliably and quickly, do no significant work inline.

Building a webhooks system event-driven architecture
Building a webhooks system with event-driven architecture. Source: CodeOpinion.
const express = require('express');
const crypto = require('crypto');
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');

const app = express();
const sqs = new SQSClient({ region: 'us-east-1' });

// CRITICAL: use raw body middleware so we can verify the signature
app.use('/webhooks/meetstream', express.raw({ type: 'application/json' }));

app.post('/webhooks/meetstream', async (req, res) => {
 // 1. Verify signature first, reject bad requests before any processing
 const signature = req.headers['x-meetstream-signature'];
 if (!signature || !verifySignature(req.body, signature, process.env.MEETSTREAM_WEBHOOK_SECRET)) {
 return res.status(401).json({ error: 'invalid signature' });
 }

 // 2. Parse the payload
 let payload;
 try {
 payload = JSON.parse(req.body.toString('utf8'));
 } catch (e) {
 return res.status(400).json({ error: 'invalid json' });
 }

 // 3. Write to queue, do NOT process inline
 try {
 await sqs.send(new SendMessageCommand({
 QueueUrl: process.env.BOT_EVENT_QUEUE_URL,
 MessageBody: JSON.stringify(payload),
 MessageDeduplicationId: `${payload.bot_id}-${payload.event}-${payload.timestamp}`,
 MessageGroupId: payload.bot_id // FIFO queue: order per bot
 }));
 } catch (err) {
 // If we can't write to queue, we have a problem but still return 200
 // to prevent MeetStream from dropping the event. Log and alert.
 console.error('Failed to enqueue webhook event', err);
 }

 // 4. Return 200 immediately, always
 return res.status(200).json({ received: true });
});

app.listen(3000);

Note the SQS FIFO queue with MessageGroupId: payload.bot_id. This ensures events for a given bot are processed in order, you won't accidentally process transcription.processed before bot.stopped for the same session.

Idempotency Patterns

Even with a reliable handler, you'll occasionally receive duplicate webhook events, network retries, infrastructure hiccups, and testing all produce duplicates. Your event processor must be idempotent: processing the same event twice should have the same outcome as processing it once.

The standard pattern uses a deduplication table with a composite key of bot_id + event_type + timestamp:

// Event processor (runs from SQS queue, separate from webhook handler)
async function processEvent(event) {
 const dedupeKey = `${event.bot_id}:${event.event}:${event.timestamp}`;

 // Atomic check-and-set using DynamoDB conditional write
 const already = await dynamodb.putItem({
 TableName: 'meetstream-processed-events',
 Item: {
 deduplication_key: { S: dedupeKey },
 processed_at: { S: new Date().toISOString() },
 ttl: { N: String(Math.floor(Date.now() / 1000) + 86400 * 7) } // 7 day TTL
 },
 ConditionExpression: 'attribute_not_exists(deduplication_key)'
 }).catch(err => {
 if (err.name === 'ConditionalCheckFailedException') return 'duplicate';
 throw err;
 });

 if (already === 'duplicate') {
 console.log(`Skipping duplicate event: ${dedupeKey}`);
 return;
 }

 // Process the event, safe to run exactly once
 switch (event.event) {
 case 'bot.inmeeting': await handleBotInMeeting(event); break;
 case 'bot.stopped': await handleBotStopped(event); break;
 case 'transcription.processed': await handleTranscriptReady(event); break;
 case 'audio.processed': await handleAudioReady(event); break;
 default: console.warn(`Unknown event type: ${event.event}`);
 }
}

Event Processing: Fetching the Transcript

When transcription.processed arrives, the transcript is ready to fetch from the URL in the payload. Fetch it from your event processor, not your webhook handler:

async function handleTranscriptReady(event) {
  const resp = await fetch(event.transcript_url, {
    headers: { 'Authorization': `Token ${process.env.MEETSTREAM_API_KEY}` }
  });
  const transcript = await resp.json();

  // Store transcript, trigger downstream processing
  await db.sessions.update(
    { bot_id: event.bot_id },
    { transcript, transcript_received_at: new Date() }
  );

  // Example: trigger summarization job
  await enqueueJob('summarize', { bot_id: event.bot_id, transcript });
}
Event-driven APIs webhook and API gateway
Event-driven APIs with webhook and API gateway integration. Source: Apache APISIX.

How MeetStream Fits

MeetStream's meeting events API delivers the full event lifecycle described above with HMAC-SHA256 signatures on every delivery. Configure your callback_url at bot creation time and your handler receives all state transitions and processed media events. The full webhook reference documents every field in each event payload.

Conclusion

Meeting bot webhooks are the backbone of your event processing system. The most important architectural decision is to accept and queue events immediately, returning 200 reliably, since MeetStream webhooks are not retried on failure, a slow or erroring handler loses data permanently. Always verify HMAC-SHA256 signatures over the raw request body using constant-time comparison. Use a deduplication table to make your event processor idempotent. For the full API reference and webhook payload schemas, see docs.meetstream.ai.

Frequently Asked Questions

What happens if my webhook endpoint returns a 500 error?

MeetStream does not retry webhook deliveries on non-2xx responses. If your handler returns 500, the event is not redelivered and the data is lost for that delivery. This makes fast, reliable 200 responses non-negotiable, the queue-backed pattern (accept, enqueue, return 200) is the correct architecture to guarantee this.

How do I verify a MeetStream webhook signature?

Compute HMAC-SHA256(raw_request_body_bytes, webhook_secret) and compare it to the X-MeetStream-Signature header value (which is prefixed with sha256=). Always use the raw bytes of the body before JSON parsing, and always use a constant-time comparison function to prevent timing attacks.

In what order do webhook events arrive?

Events arrive in the order: bot.joining then bot.inmeeting then bot.stopped, followed by post-processing events (audio.processed, transcription.processed, video.processed) in no guaranteed order. Use a FIFO queue with bot_id as the group key if you need strict ordering per session.

How should I handle duplicate webhook events?

Use a deduplication table (DynamoDB works well) with a composite key of bot_id + event_type + timestamp. Write a record atomically before processing; if the conditional write fails (key already exists), skip processing. Set a TTL of 7 days on deduplication records to prevent unbounded table growth.

What does the bot_status field mean in the bot.stopped event?

Stopped means the bot exited normally (meeting ended or automatic_leave triggered). Denied means the host explicitly rejected the bot from the waiting room. NotAllowed means the platform prevented the bot from joining (common with Zoom meetings that require specific OAuth scopes). Error indicates an unexpected failure in the bot process.