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 signupDispatch the bot
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 → queuedThe 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
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:
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
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 messagesThings 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_admissionis fundamentally different frominternal_errorand your code should treat them differently.
See also
- Failure sub-codes
- Webhooks: Signature Verification
- Billing — the bot you just dispatched cost about $0.01 if it ran for two minutes.