+ 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
- Get a free API key (no credit card)
- Go 1.21+ (for
slogand 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
- Twitter API with Python — Python version
- Twitter API with Node.js — JavaScript/Node version
- Twitter API with TypeScript — typed version
- Twitter API with Bun — Bun runtime version
- How to Scrape Twitter/X in 2026 — all scraping methods
- FetchLayer API Reference