How to Build a Post-Call Transcription Bot with MeetStream

I spent an afternoon building a bot that joins a Google Meet, waits for the call to end, and saves everything that was said into a readable text file. No manual notes, no copying from a browser tab. The meeting ends and the transcript is just there.

This guide walks through how it works and how to wire it all together in about 100 lines of Node.js.


What You Will Build

A bot that:

  • Joins any Google Meet, Zoom, or Teams link
  • Records and transcribes the meeting via MeetStream
  • Listens for a webhook event when transcription finishes
  • Fetches the full transcript and saves it as a .txt for humans and .json for integrations
  • Exits cleanly on its own when done
Source code is in MeetStream Labs under post-call-transcription.

Prerequisites

How It Works


Three API calls.

  1. Create the bot: POST to MeetStream with your meeting link, a callback_url , and a recording_config. MeetStream returns a bot_id and a transcript_id immediately.
  2. Receive webhook events: MeetStream POSTs lifecycle events to your callback_url while the meeting runs. The one that matters is transcription.processed. That's your signal to fetch.
  3. Fetch the transcript: GET the transcript using the transcript_id you saved in step one.

Project Setup

mkdir post-call-transcription
cd post-call-transcription
npm init -y
npm install express axios dotenv @ngrok/ngrok

@ngrok/ngrok saves you from opening a second terminal. It runs the tunnel programmatically and returns the public URL directly in your code.

Create a .env file:

MEETSTREAM_API_KEY=your_meetstream_api_key
MEETING_LINK=https://meet.google.com/xxx-xxxx-xxx
NGROK_AUTHTOKEN=your_ngrok_authtoken
PORT=3000

Step 1: Start the Tunnel

MeetStream needs to POST webhook events to your machine, so your local server needs a public URL. Instead of running ngrok in a separate terminal and manually copying the URL into your config, we start it in code:

src/tunnel

const ngrok = require("@ngrok/ngrok");

async function startTunnel(port) {
  const listener = await ngrok.forward({
    addr: port,
    authtoken: process.env.NGROK_AUTHTOKEN,
  });

  const url = listener.url();
  console.log(`Tunnel live → ${url}`);
  return url;
}

module.exports = { startTunnel };

This returns something like https://abc123.ngrok-free.app, which gets passed directly to MeetStream as the callback_url.


Step 2: Create the Bot

src/bot

const axios = require("axios");

const API_BASE = "https://api.meetstream.ai/api/v1";

async function createBot(meetingLink, webhookUrl) {
  const { data } = await axios.post(
    `${API_BASE}/bots/create_bot`,
    {
      meeting_link: meetingLink,
      video_required: false,
      callback_url: webhookUrl,
      recording_config: {
        transcript: {
          provider: {
            meetstream: {
              language: "auto",
              translate: false,
            },
          },
        },
      },
    },
    {
      headers: {
        Authorization: `Token ${process.env.MEETSTREAM_API_KEY}`,
        "Content-Type": "application/json",
      },
    }
  );

  console.log(`Bot created — bot_id: ${data.bot_id}`);
  console.log(`Transcript ID: ${data.transcript_id}`);

  // Store for the webhook handler
  process.env._TRANSCRIPT_ID = data.transcript_id;
}

module.exports = { createBot };

recording_config.transcript.provider is what switches transcription on. Without it, the bot joins and records but produces nothing you can fetch later. The transcript_id in the response is permanent, you get it at creation time, before the meeting even starts, and use it to fetch output after the call ends.

MeetStream supports multiple providers in that config block: meetstream, deepgram, assemblyai, sarvam, and jigsawstack. You can only use one at a time.


Step 3: Handle Webhook Events

Your server needs to respond 200 immediately when an event arrives. MeetStream does not retry on non-2xx responses, so if you fetch the transcript first and it's slow, the delivery gets marked as failed.

src/webhook

const express = require("express");
const { fetchTranscript } = require("./transcript");

function startWebhookServer(port, onReady) {
  const app = express();
  app.use(express.json());

  app.post("/webhook", (req, res) => {
    res.status(200).json({ received: true }); // acknowledge first
    handleEvent(req.body);                    // then process
  });

  app.listen(port, () => {
    console.log(`Webhook server listening on port ${port}`);
    if (onReady) onReady();
  });
}

function handleEvent({ event, bot_id, bot_status, transcript_status }) {
  switch (event) {
    case "bot.joining":
      console.log(`[${bot_id}] Bot is joining…`);
      break;

    case "bot.inmeeting":
      console.log(`[${bot_id}] Recording started.`);
      break;

    case "bot.stopped":
      console.log(`[${bot_id}] Meeting ended — status: ${bot_status}`);
      break;

    case "transcription.processed":
      if (transcript_status === "Success") {
        fetchTranscript(process.env._TRANSCRIPT_ID);
      }
      break;
  }
}

module.exports = { startWebhookServer };

Events arrive in order: bot.joining, bot.inmeeting, bot.stopped, transcription.processed. The first three are informational. Act only on the last one.


Step 4: Fetch and Save the Transcript

The response comes back as message[].participant.name and message[].words[]. The parseSegments function handles this shape, along with a fallback for the transcript[].speaker format in case the structure changes across provider versions.

src/transcript

const axios = require("axios");
const fs = require("fs");
const path = require("path");

const API_BASE = "https://api.meetstream.ai/api/v1";
const MAX_RETRIES = 12; // 12 × 5s = 60 seconds max wait

async function fetchTranscript(transcriptId, attempt = 1) {
  try {
    const { data } = await axios.get(
      `${API_BASE}/transcript/${transcriptId}/get_transcript`,
      {
        headers: {
          Authorization: `Token ${process.env.MEETSTREAM_API_KEY}`,
        },
      }
    );

    saveTranscript(transcriptId, data);
  } catch (err) {
    if (err.response?.status === 404 || err.response?.status === 202) {
      if (attempt >= MAX_RETRIES) {
        console.error(
          `Giving up after ${MAX_RETRIES} attempts. Transcript ID: ${transcriptId}`
        );
        return;
      }

      console.log(
        `Not ready yet — retrying (${attempt}/${MAX_RETRIES})…`
      );

      setTimeout(
        () => fetchTranscript(transcriptId, attempt + 1),
        5000
      );
    }
  }
}

The retry cap matters. transcription.processed fires when the job is done on MeetStream's end, but the file sometimes isn't immediately available at the fetch endpoint. Without a cap, a transient 404 becomes an infinite loop.

For the .txt output, we handle both JSON shapes and group consecutive turns from the same speaker so the transcript reads like a conversation:

function parseSegments(data) {
  // Actual API shape
  if (Array.isArray(data?.message)) {
    return data.message.map(entry => ({
      speaker: entry.participant?.name ?? "Unknown",
      text: entry.words.map(w => w.text).join(" ").trim(),
      start_time: entry.words[0]?.start_timestamp?.relative ?? null,
    }));
  }

  // Docs shape fallback
  if (Array.isArray(data?.transcript)) {
    return data.transcript;
  }

  return [];
}

The .txt output looks like this:


Step 5: Wire It Together

The entry point runs in order: tunnel first, then server, then bot.

index.js

require("dotenv").config();

const { startTunnel } = require("./src/tunnel");
const { createBot } = require("./src/bot");
const { startWebhookServer } = require("./src/webhook");

const PORT = parseInt(process.env.PORT || "3000", 10);

(async () => {
  const tunnelUrl = await startTunnel(PORT);
  const webhookUrl = `${tunnelUrl}/webhook`;

  startWebhookServer(PORT, () => {
    createBot(process.env.MEETING_LINK, webhookUrl);
  });
})();

Running It


Switching Transcription Providers

Change the provider block in src/bot.js:

MeetStream AI (built-in, no extra API key needed)

meetstream: {
  language: "auto",
  translate: false
}

Deepgram (strong accuracy for English, good speaker diarization)

deepgram: {
  model: "nova-3",
  language: "en",
  diarize: true,
  smart_format: true
}

AssemblyAI (good for chapters and named entity extraction)

assemblyai: {
  speech_models: ["universal-2"],
  speaker_labels: true
}

Sarvam AI (built for Indian languages)

sarvam: {
  model: "saaras:v3",
  language_code: "en-IN",
  with_diarization: true
}

JigsawStack (multilingual, with speaker separation)

jigsawstack: {
  language: "auto",
  translate: false,
  by_speaker: true
}

Meeting Captions (native Google Meet / Teams captions)

meeting_captions: {}

This one works differently from the others. No transcript_id is returned. After the call ends, call GET /api/v1/bots/{{bot_id}}/detail and download the caption file from the S3 link in the response. Captions need to have been enabled in the meeting itself for this to return anything.


Things That Break (and What to Do)

Two bots joining the same meeting

Happens with node --watch (the npm run dev script). Every file save restarts the process, which calls createBot() again and sends a second bot into the same meeting. Use npm start for any real meeting. --watch is fine when you're editing code with no live meeting running.

The webhook URL carrying extra text

When ngrok runs, you'll see output similar to:

https://abc123.ngrok-free.app -> http://localhost:3000

Only copy the URL before the arrow (->) when configuring your webhook URL. Do not copy the entire line.

The .env file not being picked up

On Windows, File Explorer sometimes saves .env as .env.txt because Windows hides known extensions by default. The app loads without error but none of your variables are set. If MEETING_LINK is not set shows up on npm start, run this in PowerShell:

Run the following command to check whether your .env file was accidentally saved as .env.txt:

dir -Force | Select-Object Name

If you see .env.txt, rename it using:

Rename-Item .env.txt .env

The transcript coming back empty

The response shape can vary by provider. MeetStream's API returns message[].participant.name and message[].words[], while some provider integrations may return transcript[].speaker and transcript[].text. The parseSegments function in src/transcript.js handles both automatically. If you're writing your own parser, check the raw .json file first to confirm which structure you're working with.

The transcript not arriving at all

Check callback_url first. MeetStream can't reach your server if the URL is wrong or unreachable. Add a GET /health endpoint and open it in a browser using your ngrok URL. If it loads, the tunnel is fine. If it doesn't, ngrok isn't running or the URL changed (free ngrok URLs regenerate on every restart).

The process not exiting after the transcript is saved

Express keeps Node alive because it's listening on a port. After the transcript saves, there's nothing left to do but the server stays up. process.exit(0) at the end of saveTranscript() handles this, without it you're hitting Ctrl+C after every run.

The transcript text being repeated

MeetStream's transcription uses a sliding-window algorithm to improve accuracy. As a result, consecutive words[] entries intentionally overlap. If you concatenate every entry in words[] into one string, you'll see repeated phrases.

Use parseSegments (or deduplicate overlapping windows) instead. parseSegments joins words within each speaker turn and produces clean transcript text.


Where to Take This Next

  • Meeting summaries: Pipe the .txt transcript into an LLM to generate action items, key decisions, and summaries for each speaker.
  • CRM updates: After a sales call, parse the transcript and automatically push meeting notes into Salesforce or HubSpot for the correct contact.
  • Talk time analytics: Use each segment's start_timestamp and end_timestamp to calculate speaker ratios and measure overall meeting participation.
  • Compliance logging: Store every transcript along with its bot_id, transcript_id, and timestamp to maintain a searchable audit trail.

Full source is in MeetStream Labs under post-call-transcription. Clone it, fill in two keys, run npm start.

Built with the MeetStream API. Supports Google Meet, Zoom, and Microsoft Teams.

💡
If you found this helpful, check out our other blogs, such as How to Build a Zoom Meeting Bot from Scratch
Share