meetbot / docs
Meeting Bots

Bot Quickstart

End-to-end walkthrough — dispatch a bot to a Google Meet URL, listen for the finalize webhook, fetch the manifest, and download per-speaker audio.

This is the full path from "I have a meeting URL" to "I have audio files on disk". Real working code, no pseudo-snippets.

Set up env

export MEETBOT_API_KEY=mb_live_…           # /account/keys
export MEETBOT_WEBHOOK_SECRET=whsec_…      # same page
export MEETBOT_MANIFEST_BUCKET=s3://your-bucket   # whatever you configured at signup

Dispatch the bot

dispatch.ts
import { createMeetbot } from "@meetbot/sdk";

const meetbot = createMeetbot({ apiKey: process.env.MEETBOT_API_KEY! });

const job = await meetbot.dispatchBot({
  url: "https://meet.google.com/abc-defg-hij",
  externalId: "demo-2026-05-09",
  webhooks: { onFinalize: "https://yours.example/hook/meetbot" },
  // Optional — you can let the bot figure out a sensible default.
  joinAt: undefined, // join immediately
  leaveBehavior: "when-host-leaves", // or "after-silence:300s" or "explicit"
});

console.log("dispatched", job.id, "→", job.status);
// dispatched job_01HXY1AB7M2QNTWA9YQRH5JEP6 → queued

The bot transitions through these states:

queued → joining → in_call → recording → finalizing → completed
                                            ↘ failed (with code, see sub-codes)

You can poll meetbot.getJob(job.id) if you want, but the recommended path is to wait for the webhook.

Listen for the webhook

app/api/webhook/meetbot/route.ts
import { verifyWebhookSignature } from "@meetbot/sdk";

export async function POST(req: Request) {
  const raw = await req.text();
  const ok = verifyWebhookSignature({
    body: raw,
    header: req.headers.get("x-meetbot-signature"),
    secret: process.env.MEETBOT_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(raw) as MeetbotEvent;

  if (event.type === "recording.finalized") {
    // Kick off your post-meeting pipeline. ACK fast — we time out at 10s
    // and retry up to 5 times with exponential backoff.
    queueProcess(event.data);
  }
  return new Response("ok");
}

interface MeetbotEvent {
  type: "recording.finalized" | "bot.failed" | "bot.left";
  data: {
    jobId: string;
    externalId: string;
    durationSeconds: number;
    manifestUri: string; // s3://bucket/recordings/<jobId>/manifest.json
    failureCode?: string; // present on bot.failed — see sub-codes
  };
}

See Webhooks: Signature Verification for Python and Go versions of the verifier.

Fetch the manifest

The manifest is a single JSON file in your bucket that points at every artifact for the meeting:

fetch-manifest.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "eu-central-1" });

async function fetchManifest(manifestUri: string) {
  // manifestUri looks like: s3://your-bucket/recordings/job_01HXY…/manifest.json
  const [, , bucket, ...keyParts] = manifestUri.split("/");
  const key = keyParts.join("/");
  const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
  const body = await obj.Body!.transformToString();
  return JSON.parse(body) as Manifest;
}

interface Manifest {
  jobId: string;
  startedAt: string;
  endedAt: string;
  durationSeconds: number;
  participants: Array<{ id: string; displayName: string; speakerLabel: string }>;
  artifacts: {
    tabVideo: { uri: string; mimeType: "video/webm"; bytes: number };
    perSpeakerAudio: Array<{
      participantId: string;
      uri: string;
      mimeType: "audio/ogg";
      bytes: number;
    }>;
    captions?: { uri: string; mimeType: "text/vtt" };
    chat?: { uri: string; mimeType: "application/json" };
  };
}

Download per-speaker audio

download-audio.ts
import fs from "node:fs";
import path from "node:path";

async function downloadPerSpeakerAudio(manifest: Manifest, outDir: string) {
  await fs.promises.mkdir(outDir, { recursive: true });
  await Promise.all(
    manifest.artifacts.perSpeakerAudio.map(async (track) => {
      const [, , bucket, ...keyParts] = track.uri.split("/");
      const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: keyParts.join("/") }));
      const participant = manifest.participants.find((p) => p.id === track.participantId)!;
      const filename = `${participant.speakerLabel}-${participant.displayName.replace(/\s+/g, "_")}.ogg`;
      const stream = obj.Body as NodeJS.ReadableStream;
      await new Promise<void>((resolve, reject) => {
        stream
          .pipe(fs.createWriteStream(path.join(outDir, filename)))
          .on("finish", resolve)
          .on("error", reject);
      });
    }),
  );
}

const manifest = await fetchManifest(event.data.manifestUri);
await downloadPerSpeakerAudio(manifest, `./recordings/${manifest.jobId}`);

You now have one .ogg file per participant, perfectly diarized — every file contains only that speaker's audio with silence where they weren't talking. Concatenate, mix, transcribe per-speaker, whatever you want.

What you now have on disk

./recordings/job_01HXY1AB7M2QNTWA9YQRH5JEP6/
├── A-Alice_Chen.ogg          ← per-speaker audio
├── B-Bob_Patel.ogg
├── C-Carla_Mendes.ogg
├── tab.webm                  ← the full meeting tab capture
├── captions.vtt              ← timestamped, native captions
└── chat.json                 ← in-call chat messages

Things you'll want to add for production

  • Idempotency on the webhook — we may retry; key off event.data.jobId + event.type.
  • Replay protection — store delivered webhook IDs (event.id) for ~10 minutes; reject duplicates inside that window.
  • Failure handling — see sub-codes for the taxonomy. host_denied_admission is fundamentally different from internal_error and your code should treat them differently.

See also

On this page