YouTube Transcript in Golang: net/http and Three Structs

Here's the entire program. Explanation after.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
type Transcript struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
PubDate string `json:"pubDate"`
Content string `json:"content"`
ContentSize int `json:"contentSize"`
Truncated bool `json:"truncated"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
RetryAfterSeconds int `json:"retryAfterSeconds"`
}
type envelope struct {
Result *Transcript `json:"result"`
Error *APIError `json:"error"`
}
func main() {
q := url.Values{}
q.Set("url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
req, err := http.NewRequest(http.MethodGet,
"https://youtube2text.org/api/transcribe?"+q.Encode(), nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
req.Header.Set("x-api-key", os.Getenv("YT2TEXT_KEY"))
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer resp.Body.Close()
var env envelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
fmt.Fprintln(os.Stderr, "decode:", err)
os.Exit(1)
}
if env.Error != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", env.Error.Code, env.Error.Message)
os.Exit(1)
}
fmt.Println(env.Result.Title)
fmt.Printf("%d chars, truncated=%v\n",
env.Result.ContentSize, env.Result.Truncated)
}
go run main.go, done. No third-party modules, which is how getting a YouTube transcript in Golang should look — the API is plain HTTPS returning JSON, so net/http and encoding/json cover everything. There's no official Go library for this, and I'd argue that's a feature.
The envelope trick
Success responses put data under result; failures put a structured object under error. Decoding both into one struct with two pointer fields means a single Decode call handles every response — whichever pointer is non-nil is your answer. No re-reading the body, no sniffing Content-Type, and env.Error.Status matches the HTTP status if you want to double-check.
Codes worth a switch statement
APIError.Code is stable, so branch on it rather than on message strings:
switch env.Error.Code {
case "RATE_LIMIT_EXCEEDED":
wait := time.Duration(env.Error.RetryAfterSeconds) * time.Second
time.Sleep(wait) // then retry the request
case "TRANSCRIPT_UNAVAILABLE":
// video exists but has no caption track — skip permanently
case "VIDEO_NOT_FOUND", "INVALID_URL":
// bad input; log and move on
default:
// UNAUTHORIZED, VALIDATION_ERROR, YOUTUBE_ERROR
}
TRANSCRIPT_UNAVAILABLE is the one people mishandle. Transcripts come from YouTube's caption tracks — manual or auto-generated, English preferred, first available language otherwise. If a video has captions turned off, there is no transcript to fetch, and retrying with exponential backoff will not make captions grow. Treat it as a permanent skip in batch jobs, or your worker queue fills with zombies.
Parameters and keys
Two query parameters total. url takes any YouTube URL shape or a bare 11-character id. maxChars caps output length (150000 is default and max) — useful when the transcript feeds an LLM and you're paying per token, which is most Go use cases I hear about, usually RAG over a whole channel.
For a key without any signup, curl https://youtube2text.org/api/demo-key returns a working one — shared across all anonymous users at 5 videos per month per IP, so it's strictly for trying things out. If you're weighing this against shelling out to yt-dlp for subtitle files, I wrote a comparison of yt-dlp subtitles vs the API; and if part of your stack is Node, the same call in JavaScript is even shorter.
For real usage, sign in with Google at youtube2text.org/app/keys — no card, no phone, free tier of 5 videos a month, paid tiers from $5.99 for 50 up to $19.99 unlimited. The Go code above doesn't change; only the env var does.