Build a Discord Bot That Fetches YouTube Transcripts
Zied · 8/12/2026 · 7 min read
Build a Discord Bot That Fetches YouTube Transcripts
Paste a YouTube link into a Discord channel and get the full transcript back in seconds. That is the entire premise of this bot, and it takes less than 100 lines of Node.js to build. The heavy lifting, extracting captions across 125 languages while dodging YouTube's IP blocks, goes to the YouTube Transcriber API.
This guide walks through registering a slash command, parsing the video ID, calling the transcript API, formatting the response for Discord, and handling the two failure modes that trip up most first implementations.
What You Will Build
A Discord slash command /transcript that accepts a YouTube URL and returns the transcript as a formatted message. If the transcript is longer than Discord's 2,000-character limit, the bot paginates across multiple replies. For videos with captions disabled, it replies with a clear error.
The same pattern powers more sophisticated tools. If you want to add an AI summarization layer on top, the Build a Slack Bot That Summarizes YouTube Videos guide covers the summarization step in detail and translates directly to Discord.
Prerequisites
- Node.js 18 or later
- A Discord application with a bot token (created at discord.com/developers/applications)
- A YouTube Transcriber API key (free at getyoutubetranscriber.com)
- The
discord.jsv14 library and the nativefetchAPI (ornode-fetch)
Install the one dependency:
npm install discord.js
Register the Slash Command
Discord requires you to register slash commands before they appear in the UI. Create a deploy-commands.js file and run it once:
// deploy-commands.js
const { REST, Routes, SlashCommandBuilder } = require('discord.js');
const commands = [
new SlashCommandBuilder()
.setName('transcript')
.setDescription('Fetch the transcript for a YouTube video')
.addStringOption(option =>
option
.setName('url')
.setDescription('YouTube video URL or ID')
.setRequired(true)
)
.toJSON()
];
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
(async () => {
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID),
{ body: commands }
);
console.log('Slash command registered.');
})();
Run it with:
DISCORD_TOKEN=your_token CLIENT_ID=your_app_id node deploy-commands.js
Global command registration can take up to an hour to propagate. For faster iteration during development, register to a specific guild by replacing Routes.applicationCommands with Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID).
Parse the Video ID
YouTube URLs come in several shapes. A helper function normalizes them to a plain video ID:
function extractVideoId(input) {
// Already a bare ID like dQw4w9WgXcQ
if (/^[a-zA-Z0-9_-]{11}$/.test(input)) return input;
try {
const url = new URL(input);
if (url.hostname === 'youtu.be') return url.pathname.slice(1);
return url.searchParams.get('v');
} catch {
return null;
}
}
Passing the raw ID or full URL directly to the API also works since the video_url parameter accepts both forms, but normalizing early makes error messages cleaner.
Call the YouTube Transcript API
The transcript endpoint is a single authenticated GET request:
GET https://getyoutubetranscriber.com/api/v2/transcript
Required parameter: video_url. Optional parameters include lang for a specific language code and send_metadata=true to get the video title alongside the transcript.
async function fetchTranscript(videoId, lang = null) {
const params = new URLSearchParams({
video_url: videoId,
send_metadata: 'true',
include_timestamp: 'false', // omit timestamps for cleaner Discord output
});
if (lang) params.set('lang', lang);
const res = await fetch(
`https://getyoutubetranscriber.com/api/v2/transcript?${params}`,
{
headers: {
Authorization: `Bearer ${process.env.YT_API_KEY}`,
},
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw Object.assign(new Error(err.title || 'API error'), { status: res.status });
}
return res.json();
}
A successful response looks like this:
{
"video_id": "dQw4w9WgXcQ",
"language": "en",
"transcript": [
{ "text": "We're no strangers to love", "start": 18640, "duration": 3240 },
{ "text": "You know the rules and so do I", "start": 21880, "duration": 3320 }
],
"metadata": {
"title": "Rick Astley - Never Gonna Give You Up",
"author_name": "Rick Astley",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
}
}
Join the text fields into a single string for the Discord reply:
function formatTranscript(data) {
const title = data.metadata?.title ?? data.video_id;
const body = data.transcript.map(s => s.text).join(' ');
return { title, body };
}
Handle Rate Limits and Long Messages
Two failure modes matter here.
Rate limit responses (429)
The API returns 429 when you exceed the request rate. Retry with exponential backoff:
async function fetchWithRetry(videoId, lang, maxAttempts = 3) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fetchTranscript(videoId, lang);
} catch (err) {
if (err.status === 429 || err.status === 408) {
// 408 is a temporary upstream failure and is also safe to retry
const delay = 1000 * 2 ** attempt;
await new Promise(r => setTimeout(r, delay));
continue;
}
throw err; // non-retryable, surface immediately
}
}
throw new Error('Max retry attempts reached');
}
For more detail on building a resilient retry loop around the API, see Retry Strategies for a Reliable YouTube Transcript Pipeline.
Message length limits
Discord enforces a 2,000-character cap per message. A transcript for a 30-minute video can run to 20,000 characters or more. Chunk the body before sending:
function chunkText(text, maxLen = 1900) {
const chunks = [];
let start = 0;
while (start < text.length) {
let end = start + maxLen;
// Break at the last space before the limit to avoid splitting words
if (end < text.length) {
const boundary = text.lastIndexOf(' ', end);
if (boundary > start) end = boundary;
}
chunks.push(text.slice(start, end).trim());
start = end;
}
return chunks;
}
Wire It Together
The full interaction handler defers the reply immediately to avoid Discord's 3-second timeout, then sends the transcript in chunks:
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'transcript') return;
// Defer publicly so the channel sees the bot is working
await interaction.deferReply();
const input = interaction.options.getString('url');
const videoId = extractVideoId(input);
if (!videoId) {
await interaction.editReply('That does not look like a valid YouTube URL or video ID.');
return;
}
try {
const data = await fetchWithRetry(videoId);
const { title, body } = formatTranscript(data);
const chunks = chunkText(body);
// Send the first chunk as the deferred reply
await interaction.editReply(`**${title}**\n\n${chunks[0]}`);
// Follow up with remaining chunks
for (let i = 1; i < chunks.length; i++) {
await interaction.followUp({ content: chunks[i] });
}
} catch (err) {
if (err.status === 404) {
await interaction.editReply('No transcript found for that video. Captions may be disabled.');
} else if (err.status === 401) {
await interaction.editReply('API key is missing or invalid.');
} else {
await interaction.editReply(`Something went wrong: ${err.message}`);
}
}
});
client.login(process.env.DISCORD_TOKEN);
Gotcha: Ephemeral vs Public Replies
Choosing between ephemeral and public replies affects how the bot feels to use.
An ephemeral reply (pass { ephemeral: true } to deferReply) is visible only to the user who invoked the command. This makes sense for raw transcript dumps, which are long and clutters channels. The downside is that follow-up messages from interaction.followUp are also ephemeral by default when the initial reply was ephemeral, so multi-part transcripts stay private.
A public reply is visible to everyone. This works well when you add a summarization step and send a short paragraph rather than the full text. It also lets other members upvote or react to useful finds.
A practical pattern: defer ephemerally, then ask the user whether they want to share the summary publicly:
await interaction.deferReply({ ephemeral: true });
// ... fetch and summarize ...
await interaction.editReply({
content: summary,
components: [shareButton] // a button that posts to the channel
});
For an LLM-based summarization layer that feeds into this pattern, the Build a YouTube Video Q&A Chatbot with Transcripts guide shows how to pass transcript text to GPT or Claude and shape the output.
Environment Variables
Keep credentials out of source code:
DISCORD_TOKEN=your_discord_bot_token
CLIENT_ID=your_discord_application_id
YT_API_KEY=your_youtube_transcriber_api_key
Load them with dotenv in development or inject them through your deployment platform in production.
What to Build Next
Once the basic bot works, a few additions make it significantly more useful:
- Language selection. Add a second slash command option for
langand pass it through to the API. The API supports 125+ language codes, so users can pull French, Spanish, or Japanese captions from the same video. - Summarization. Join the transcript, send it to an LLM, and post a 3-5 sentence summary instead of raw text. Discord becomes a lightweight research tool for your community.
- Channel monitoring. Combine the transcript endpoint with the channel uploads endpoint to post transcripts automatically whenever a subscribed creator publishes a new video.
Discord has 19 million active servers per week according to the platform's own reporting, and developer-built bots are one of the main reasons communities stay engaged. A bot that surfaces video content as searchable text adds real value to any server focused on learning, research, or media.
Get started with 100 free credits at getyoutubetranscriber.com. No credit card required. The docs at getyoutubetranscriber.com/docs cover the full parameter list, error reference, and bulk transcript endpoint if you need to scale beyond single-video lookups.