+ Integration Guide

· Updated August 28, 2026

Twitter/X API with Go: Complete Guide

How to search X/Twitter and scrape tweets using Go and the FetchLayer API. Clean, idiomatic Go examples with proper error handling and struct decoding.

Written by Alex P.

  • Go
  • Golang
  • twitter scraping
  • X API
  • Twitter API
  • API integration

This guide shows how to use the FetchLayer Twitter/X 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/twitter"

var apiKey = os.Getenv("FETCHLAYER_API_KEY")

func twitter(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 TwitterAuthor struct {
	Handle         string `json:"handle"`
	DisplayName    string `json:"displayName"`
	IsVerified     bool   `json:"isVerified"`
	FollowersCount int    `json:"followersCount,omitempty"`
}

type Tweet struct {
	ID           string        `json:"id"`
	Text         string        `json:"text"`
	Author       TwitterAuthor `json:"author"`
	CreatedAt    string        `json:"createdAt"`
	LikeCount    int           `json:"likeCount"`
	RetweetCount int           `json:"retweetCount"`
	ReplyCount   int           `json:"replyCount"`
	ViewCount    int           `json:"viewCount,omitempty"`
	URL          string        `json:"url"`
}

type SearchResponse struct {
	Results []Tweet `json:"results"`
	Cursor  string  `json:"cursor,omitempty"`
}

type TweetDetailResponse struct {
	ID           string        `json:"id"`
	Text         string        `json:"text"`
	Author       TwitterAuthor `json:"author"`
	CreatedAt    string        `json:"createdAt"`
	LikeCount    int           `json:"likeCount"`
	RetweetCount int           `json:"retweetCount"`
	ReplyCount   int           `json:"replyCount"`
	QuoteCount   int           `json:"quoteCount"`
	ViewCount    int           `json:"viewCount"`
	URL          string        `json:"url"`
	Lang         string        `json:"lang"`
}

type UserProfile struct {
	Handle         string `json:"handle"`
	DisplayName    string `json:"displayName"`
	Description    string `json:"description"`
	IsVerified     bool   `json:"isVerified"`
	FollowersCount int    `json:"followersCount"`
	FollowingCount int    `json:"followingCount"`
	TweetsCount    int    `json:"tweetsCount"`
	JoinedAt       string `json:"joinedAt"`
	Location       string `json:"location,omitempty"`
	Website        string `json:"website,omitempty"`
}

Search Twitter/X

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

	for _, tweet := range data.Results {
		fmt.Printf("[%d likes] @%s: %s\n", tweet.LikeCount, tweet.Author.Handle, truncate(tweet.Text, 100))
	}
}

Run it:

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

Get a Tweet by ID

var tweet TweetDetailResponse
err := twitter("tweet-detail", map[string]any{
	"tweetId": "1942939879222220800",
}, &tweet)
if err != nil {
	log.Fatal(err)
}

fmt.Printf("@%s: %s\n", tweet.Author.Handle, tweet.Text)
fmt.Printf("%d likes · %d retweets · %d replies\n", tweet.LikeCount, tweet.RetweetCount, tweet.ReplyCount)

Get a User Profile

var profile UserProfile
err := twitter("user-profile-details", map[string]any{
	"handle": "openai",
}, &profile)
if err != nil {
	log.Fatal(err)
}

fmt.Printf("%s (@%s)\n", profile.DisplayName, profile.Handle)
fmt.Printf("Followers: %d | Following: %d | Tweets: %d\n",
	profile.FollowersCount, profile.FollowingCount, profile.TweetsCount)

Get a User’s Tweets

type UserTweetsResponse struct {
	Tweets []Tweet `json:"tweets"`
	Cursor string  `json:"cursor,omitempty"`
}

var data UserTweetsResponse
err := twitter("user-tweets", map[string]any{
	"handle": "rauchg",
	"count":  20,
}, &data)
if err != nil {
	log.Fatal(err)
}

for _, tweet := range data.Tweets {
	fmt.Printf("[%d likes] %s\n", tweet.LikeCount, truncate(tweet.Text, 120))
}

Get Followers

type AccountEntry struct {
	Handle         string `json:"handle"`
	DisplayName    string `json:"displayName"`
	IsVerified     bool   `json:"isVerified"`
	FollowersCount int    `json:"followersCount"`
}

type FollowersResponse struct {
	Accounts []AccountEntry `json:"accounts"`
	Cursor   string         `json:"cursor,omitempty"`
}

var followers FollowersResponse
err := twitter("followers", map[string]any{
	"handle": "openai",
	"count":  50,
}, &followers)
if err != nil {
	log.Fatal(err)
}

for _, account := range followers.Accounts {
	fmt.Printf("@%s%d followers (verified: %v)\n",
		account.Handle, account.FollowersCount, account.IsVerified)
}

Full Example: Twitter Audience Analyzer

// analyzer.go — run with: FETCHLAYER_API_KEY=ss-... go run analyzer.go <handle>
func main() {
	if len(os.Args) < 2 {
		fmt.Fprintf(os.Stderr, "Usage: analyzer <handle>\n")
		os.Exit(1)
	}
	handle := os.Args[1]

	var profile UserProfile
	if err := twitter("user-profile-details", map[string]any{"handle": handle}, &profile); err != nil {
		log.Fatal(err)
	}

	var data UserTweetsResponse
	if err := twitter("user-tweets", map[string]any{"handle": handle, "count": 50}, &data); err != nil {
		log.Fatal(err)
	}

	totalLikes := 0
	for _, t := range data.Tweets {
		totalLikes += t.LikeCount
	}
	avgLikes := float64(totalLikes) / float64(max(1, len(data.Tweets)))

	fmt.Printf("%s (@%s)\n", profile.DisplayName, profile.Handle)
	fmt.Printf("Followers: %d\n", profile.FollowersCount)
	fmt.Printf("Avg engagement: %.0f likes per tweet\n", avgLikes)
	if profile.FollowersCount > 0 {
		fmt.Printf("Engagement rate: %.2f%%\n", (avgLikes/float64(profile.FollowersCount))*100)
	}
}

func truncate(s string, maxLen int) string {
	if len(s) <= maxLen {
		return s
	}
	return s[:maxLen] + "..."
}

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 a handle 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 twitter(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.


A worker pool over a channel

For a fixed list of handles, a worker pool reading from a jobs channel is the idiomatic Go shape — bounded concurrency, no semaphore bookkeeping, and results collected on a second channel:

type profileJob struct {
	Handle string
}

type profileResult struct {
	Handle  string
	Profile UserProfile
	Err     error
}

func fetchProfiles(ctx context.Context, handles []string, workers int) []profileResult {
	jobs := make(chan profileJob, len(handles))
	results := make(chan profileResult, len(handles))

	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for job := range jobs {
				var profile UserProfile
				err := twitterCtx(ctx, "user-profile-details", map[string]any{
					"handle": job.Handle,
				}, &profile)
				results <- profileResult{Handle: job.Handle, Profile: profile, Err: err}
			}
		}()
	}

	for _, h := range handles {
		jobs <- profileJob{Handle: h}
	}
	close(jobs) // workers exit their range loop once drained

	// Close results only after every worker has returned, so the
	// range below terminates instead of blocking forever.
	go func() {
		wg.Wait()
		close(results)
	}()

	var out []profileResult
	for r := range results {
		out = append(out, r)
	}
	return out
}

The ordering here is the part people get wrong: close(jobs) after queuing, then a goroutine that waits and closes results. Close results on the main goroutine before workers finish and you’ll panic on a send to a closed channel; never close it and the range blocks forever.

Buffering both channels to len(handles) means queuing never blocks, so the main goroutine can fill the queue and move straight to collecting.


Rate limiting with x/time/rate

Follower endpoints page, and paging fast is the quickest way to a 429. Go’s standard rate limiter gives you a token bucket that spreads requests evenly:

go get golang.org/x/time/rate
import "golang.org/x/time/rate"

// 5 requests per second, allowing short bursts of 2.
var limiter = rate.NewLimiter(rate.Every(200*time.Millisecond), 2)

func twitterLimited(ctx context.Context, endpoint string, body, out any) error {
	// Blocks until a token is available or ctx is cancelled.
	if err := limiter.Wait(ctx); err != nil {
		return fmt.Errorf("rate limiter: %w", err)
	}
	return twitterCtx(ctx, endpoint, body, out)
}

Because limiter.Wait respects the context, a cancelled parent operation stops queued requests from firing rather than letting them proceed pointlessly.

Combined with a worker pool, the limiter — not the worker count — becomes the thing controlling request rate. Workers govern parallelism; the limiter governs throughput. That separation is what lets you run ten workers without sending ten requests at once.


Paginating followers with a bounded loop

type followersResponse struct {
	Followers  []UserProfile `json:"followers"`
	NextCursor string        `json:"nextCursor"`
}

func collectFollowers(ctx context.Context, handle string, maxPages int) ([]UserProfile, error) {
	var all []UserProfile
	cursor := ""

	for page := 0; page < maxPages; page++ {
		body := map[string]any{"handle": handle, "count": 100}
		if cursor != "" {
			body["cursor"] = cursor
		}

		var resp followersResponse
		if err := twitterLimited(ctx, "user-followers", body, &resp); err != nil {
			// Return what we have alongside the error — a partial
			// page-three failure shouldn't discard pages one and two.
			return all, fmt.Errorf("page %d: %w", page, err)
		}

		all = append(all, resp.Followers...)

		if resp.NextCursor == "" || len(resp.Followers) == 0 {
			break
		}
		cursor = resp.NextCursor
	}

	return all, nil
}

Returning all and the error is deliberate. Go lets you do that, and for paginated collection it’s almost always what the caller wants — two pages of data plus a note about what failed beats an empty slice.


What’s Next