+ Integration Guide

· Updated August 28, 2026

Reddit Scraping API with Go: Complete Guide

How to search Reddit and scrape posts using Go and the FetchLayer API. Clean, idiomatic Go examples with proper error handling and struct decoding.

Written by Alex P.

  • Go
  • Golang
  • reddit scraping
  • reddit API
  • API integration

This guide shows how to use the FetchLayer Reddit API with Go. All examples use the standard library — no third-party HTTP clients needed.


Setup

  1. Get a free API key (no credit card)
  2. Go 1.21+ (for slog and modern stdlib features)

API Client

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const baseURL = "https://api.fetchlayer.dev/reddit"

var apiKey = os.Getenv("FETCHLAYER_API_KEY")

func reddit(endpoint string, body any, result any) error {
	payload, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}

	req, err := http.NewRequest("POST", baseURL+"/"+endpoint, bytes.NewReader(payload))
	if err != nil {
		return fmt.Errorf("request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return fmt.Errorf("do: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("API returned %d", resp.StatusCode)
	}

	return json.NewDecoder(resp.Body).Decode(result)
}

Types

type RedditPost struct {
	Title       string `json:"title"`
	Subreddit   string `json:"subreddit"`
	Author      string `json:"author"`
	Score       int    `json:"score"`
	NumComments int    `json:"numComments"`
	URL         string `json:"url"`
	Selftext    string `json:"selftext,omitempty"`
}

type SearchResponse struct {
	Results []RedditPost `json:"results"`
}

type CommunityPostsResponse struct {
	Subreddit string       `json:"subreddit"`
	Posts     []RedditPost `json:"posts"`
}

type Comment struct {
	Author  string    `json:"author"`
	Body    string    `json:"body"`
	Score   int       `json:"score"`
	Replies []Comment `json:"replies,omitempty"`
}

type PostResponse struct {
	Title       string    `json:"title"`
	Author      string    `json:"author"`
	Score       int       `json:"score"`
	NumComments int       `json:"numComments"`
	Selftext    string    `json:"selftext"`
	URL         string    `json:"url"`
	Comments    []Comment `json:"comments"`
}

type UserProfile struct {
	Username    string `json:"username"`
	DisplayName string `json:"displayName"`
	TotalKarma  int    `json:"totalKarma"`
	AccountAge  string `json:"accountAge"`
}

Search Reddit

func main() {
	var data SearchResponse
	err := reddit("search", map[string]any{
		"query": "best Go web frameworks",
		"sort":  "top",
		"limit": 10,
	}, &data)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	for _, post := range data.Results {
		fmt.Printf("[%d] %s — r/%s\n", post.Score, post.Title, post.Subreddit)
	}
}

Run it:

FETCHLAYER_API_KEY=ss-your-key go run main.go

Get Subreddit Posts

var data CommunityPostsResponse
err := reddit("community-posts", map[string]any{
	"subreddit": "golang",
	"sort":      "top",
	"time":      "week",
	"limit":     20,
}, &data)
if err != nil {
	log.Fatal(err)
}

for _, post := range data.Posts {
	fmt.Printf("[%d] %s\n", post.Score, post.Title)
}

Scrape a Post with Comments

var thread PostResponse
err := reddit("post", map[string]any{
	"url":   "https://www.reddit.com/r/golang/comments/abc123/some_post/",
	"pages": 2,
}, &thread)
if err != nil {
	log.Fatal(err)
}

fmt.Printf("%s%d comments\n", thread.Title, len(thread.Comments))
for _, c := range thread.Comments[:min(5, len(thread.Comments))] {
	fmt.Printf("  %s: %s\n", c.Author, truncate(c.Body, 100))
}

Get User Profile

var profile UserProfile
err := reddit("user-profile", map[string]any{
	"username": "spez",
}, &profile)
if err != nil {
	log.Fatal(err)
}

fmt.Printf("%s%d karma — %s\n", profile.Username, profile.TotalKarma, profile.AccountAge)

Full Example: Reddit Monitor

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const baseURL = "https://api.fetchlayer.dev/reddit"

var apiKey = os.Getenv("FETCHLAYER_API_KEY")

type RedditPost struct {
	Title     string `json:"title"`
	Subreddit string `json:"subreddit"`
	Score     int    `json:"score"`
	URL       string `json:"url"`
}

type SearchResponse struct {
	Results []RedditPost `json:"results"`
}

func reddit(endpoint string, body any, result any) error {
	payload, _ := json.Marshal(body)
	req, _ := http.NewRequest("POST", baseURL+"/"+endpoint, bytes.NewReader(payload))
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		return fmt.Errorf("API returned %d", resp.StatusCode)
	}
	return json.NewDecoder(resp.Body).Decode(result)
}

func main() {
	keyword := "your-brand"
	if len(os.Args) > 1 {
		keyword = os.Args[1]
	}

	subreddits := []string{"startups", "SaaS", "webdev", "golang"}

	fmt.Printf("Searching for %q...\n\n", keyword)

	for _, sub := range subreddits {
		var data SearchResponse
		err := reddit("search", map[string]any{
			"query":     keyword,
			"subreddit": sub,
			"sort":      "new",
			"limit":     5,
		}, &data)
		if err != nil {
			fmt.Fprintf(os.Stderr, "r/%s: %v\n", sub, err)
			continue
		}

		if len(data.Results) > 0 {
			fmt.Printf("r/%s:\n", sub)
			for _, post := range data.Results {
				fmt.Printf("  [%d] %s\n", post.Score, post.Title)
				fmt.Printf("  %s\n\n", post.URL)
			}
		}
	}
}
FETCHLAYER_API_KEY=ss-your-key go run main.go "react vs svelte"

Handling Errors and Rate Limits

The client above returns a generic error for any non-200 response, which is enough for a one-off script but not for a long-running job. FetchLayer returns standard HTTP status codes: 401 for a missing or invalid API key, 400 when a required field like query or subreddit is missing, and 429 once you exceed your plan’s request rate. Distinguish them so the caller can decide what to do about each:

import (
	"errors"
	"strconv"
	"time"
)

var ErrRateLimited = errors.New("rate limited")

func reddit(endpoint string, body any, result any) error {
	for attempt := 0; attempt <= 2; attempt++ {
		payload, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("marshal: %w", err)
		}

		req, err := http.NewRequest("POST", baseURL+"/"+endpoint, bytes.NewReader(payload))
		if err != nil {
			return fmt.Errorf("request: %w", err)
		}
		req.Header.Set("Authorization", "Bearer "+apiKey)
		req.Header.Set("Content-Type", "application/json")

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return fmt.Errorf("do: %w", err)
		}
		defer resp.Body.Close()

		switch resp.StatusCode {
		case http.StatusOK:
			return json.NewDecoder(resp.Body).Decode(result)
		case http.StatusUnauthorized:
			return fmt.Errorf("invalid or missing API key")
		case http.StatusBadRequest:
			return fmt.Errorf("bad request to /%s", endpoint)
		case http.StatusTooManyRequests:
			if attempt == 2 {
				return ErrRateLimited
			}
			wait, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
			if wait == 0 {
				wait = 5
			}
			time.Sleep(time.Duration(wait) * time.Second)
			continue
		default:
			return fmt.Errorf("API returned %d", resp.StatusCode)
		}
	}
	return ErrRateLimited
}

A 429 mid-run is expected behavior on the free tier, not a bug — the retry above respects Retry-After rather than hammering the endpoint again immediately. Check for ErrRateLimited with errors.Is in the caller so it’s handled differently from an auth or validation failure.


Context timeouts

An http.Client without a timeout will wait indefinitely. In a scheduled job that means a hung request keeps the process alive until something else kills it. Set a client-level timeout and pass a per-request context:

var client = &http.Client{Timeout: 60 * time.Second}

func redditCtx(ctx context.Context, endpoint string, body any, out any) error {
	payload, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("marshal request: %w", err)
	}

	req, err := http.NewRequestWithContext(
		ctx, http.MethodPost,
		baseURL+"/"+endpoint,
		bytes.NewReader(payload),
	)
	if err != nil {
		return fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("call %s: %w", endpoint, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("%s returned %d: %s", endpoint, resp.StatusCode, msg)
	}

	return json.NewDecoder(resp.Body).Decode(out)
}

Using NewRequestWithContext rather than NewRequest is what makes cancellation actually propagate — the caller’s ctx can abort an in-flight request, which matters when a parent operation is already doomed:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

var result SearchResponse
if err := redditCtx(ctx, "search", map[string]any{
	"query": "golang concurrency",
	"sort":  "top",
	"limit": 10,
}, &result); err != nil {
	log.Fatal(err)
}

Concurrent scans with errgroup

Go’s concurrency is the reason to use it for this kind of work. errgroup gives you goroutines with error propagation and a built-in concurrency limit:

go get golang.org/x/sync/errgroup
import "golang.org/x/sync/errgroup"

type subredditResult struct {
	Subreddit string
	Posts     []Post
}

func scanSubreddits(ctx context.Context, keyword string, subs []string) ([]subredditResult, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(3) // cap in-flight requests so we don't trigger a 429

	results := make([]subredditResult, len(subs))

	for i, sub := range subs {
		i, sub := i, sub // capture per iteration (unnecessary on Go 1.22+)
		g.Go(func() error {
			var resp SearchResponse
			err := redditCtx(ctx, "search", map[string]any{
				"query":     keyword,
				"subreddit": sub,
				"sort":      "new",
				"limit":     10,
			}, &resp)
			if err != nil {
				return fmt.Errorf("r/%s: %w", sub, err)
			}
			results[i] = subredditResult{Subreddit: sub, Posts: resp.Results}
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return nil, err
	}
	return results, nil
}

Writing into results[i] by index needs no mutex — each goroutine owns exactly one slot, so there’s no shared write. That’s cleaner and faster than appending under a lock.

Note that errgroup.WithContext cancels the shared context on the first error, so a single failure aborts the rest. That’s right for a pipeline where partial data is useless. If you’d rather collect what succeeded, use a plain sync.WaitGroup and store errors per slot instead:

type outcome struct {
	Result subredditResult
	Err    error
}

func scanTolerant(ctx context.Context, keyword string, subs []string) []outcome {
	out := make([]outcome, len(subs))
	sem := make(chan struct{}, 3)
	var wg sync.WaitGroup

	for i, sub := range subs {
		wg.Add(1)
		go func(i int, sub string) {
			defer wg.Done()
			sem <- struct{}{}        // acquire
			defer func() { <-sem }() // release

			var resp SearchResponse
			if err := redditCtx(ctx, "search", map[string]any{
				"query": keyword, "subreddit": sub, "sort": "new", "limit": 10,
			}, &resp); err != nil {
				out[i] = outcome{Err: fmt.Errorf("r/%s: %w", sub, err)}
				return
			}
			out[i] = outcome{Result: subredditResult{Subreddit: sub, Posts: resp.Results}}
		}(i, sub)
	}

	wg.Wait()
	return out
}

The buffered channel is Go’s idiomatic semaphore — capacity three means at most three goroutines are past the acquire at any moment.


What’s Next