Integration Guide

Twitter/X API Code Examples: Python, Node.js, TypeScript, Go

Working Twitter/X API examples in Python, Node.js, TypeScript, Go and Bun: search tweets, read timelines, replies, profiles and followers, paginate and handle errors, with no X developer account.

Written by Alex P.

  • twitter API
  • X API
  • twitter API python
  • twitter API nodejs
  • twitter API typescript
  • twitter API go
  • bun
  • code examples

Short answer: you can read public Twitter/X data from any language with one HTTP request. FetchLayer’s Twitter/X API takes a POST with a JSON body and your key, and returns tweets, timelines, replies, profiles and follower lists as JSON. You don’t need an X developer account. It costs $1.99 per 1,000 requests with 30 free to start, and the same data is available to AI agents over MCP at https://mcp.fetchlayer.dev. Every example below was run against the live API on 4 November 2026.

Jump to your language: Python · Node.js · TypeScript · Go · Bun

How every request works

All five languages make the same call:

  • URL: POST https://api.fetchlayer.dev/twitter/<endpoint>
  • Headers: Authorization: Bearer ss-your-fetchlayer-key and Content-Type: application/json
  • Body: the endpoint’s parameters as JSON, for example {"query": "postgres performance", "product": "Latest"}
  • Response: JSON. A tweet carries id, createdAt, text, author (with handle and followersCount), counts (likes, reposts, replies, views) and url. Lists return nextCursor for the next page.

Get a key and export it as FETCHLAYER_API_KEY; every example reads it from there.

The endpoints you’ll use most

EndpointBodyReturns
searchquery, product (Top, Latest, People, Media, Lists)tweets (or profiles for People)
user-tweetshandle, optional cursorAn account’s recent tweets
tweet-detailtweetIdOne tweet
tweet-repliestweetId, optional cursorreplies to a tweet
user-profile-detailshandleThe account’s profile
followers, followinghandle, optional cursorprofiles, about 50 to 70 per page

All 11 endpoints and every field are in the Twitter/X API reference.

Errors are the same everywhere

Every error has the same body:

{"error":"Missing required parameter: handle","status":400,"code":"invalid_input","retryable":false,"requestId":"…"}

Retry only when retryable is true (503 temporarily_unavailable or time_limit, 502 upstream_error, 500 internal_error), with a backoff. A 400 means fix the request, 401 check the key, 402 top up, 429 slow down. Failed requests are never billed.


Python

requests is all you need. This searches, walks an account’s timeline across two pages, and reads one page of its followers:

import os
import time
import requests

API = "https://api.fetchlayer.dev/twitter"
HEADERS = {"Authorization": f"Bearer {os.environ['FETCHLAYER_API_KEY']}"}


def call(endpoint, body, attempts=3):
    """POST to an endpoint; retry only what the API marks retryable."""
    for attempt in range(attempts):
        res = requests.post(f"{API}/{endpoint}", json=body, headers=HEADERS, timeout=90)
        if res.ok:
            return res.json()
        err = res.json()
        if not err.get("retryable") or attempt == attempts - 1:
            raise RuntimeError(f"{err['code']}: {err['error']} (request {err['requestId']})")
        time.sleep(2 ** attempt)


# Latest tweets matching a keyword
results = call("search", {"query": "postgres performance", "product": "Latest"})
for t in results["tweets"][:5]:
    print(f"@{t['author']['handle']}: {t['text'][:60]!r}  ({t['counts']['likes']} likes)")


# An account's timeline, page by page: pass nextCursor back as cursor.
# A pinned tweet can appear on more than one page, so key by id.
def timeline(handle, pages):
    seen, cursor = {}, None
    for _ in range(pages):
        body = {"handle": handle}
        if cursor:
            body["cursor"] = cursor
        data = call("user-tweets", body)
        for t in data["tweets"]:
            seen[t["id"]] = t
        cursor = data.get("nextCursor")
        if not cursor:
            break
    return list(seen.values())


tweets = timeline("bunjavascript", pages=2)
top = max(tweets, key=lambda t: t["counts"]["views"] or 0)
print(len(tweets), "tweets; most viewed:", top["counts"]["views"], top["url"])

# Who follows an account (one page)
page = call("followers", {"handle": "bunjavascript"})
biggest = sorted(page["profiles"], key=lambda p: p["followersCount"] or 0, reverse=True)[:3]
print([f"@{p['handle']} ({p['followersCount']})" for p in biggest])

Output from the run (trimmed):

@petereliaskraft: 'Fun optimization story!\n\nWhen we were scaling DBOS to suppor'  (23 likes)
51 tweets; most viewed: 4736824 https://x.com/bunjavascript/status/1995916660847640934
['@wesbos (446664)', '@theo (420128)', '@kentcdodds (334959)']

Paginating followers

followers and following return one page per request; X sets the page size, about 50 to 70 accounts per page. Use nextCursor for more: pass it back as cursor with the same other parameters.

import os
import requests

API = "https://api.fetchlayer.dev/twitter"
HEADERS = {"Authorization": f"Bearer {os.environ['FETCHLAYER_API_KEY']}"}


def followers(handle, pages):
    """Yield an account's followers, page by page: pass nextCursor back as cursor."""
    cursor = None
    for _ in range(pages):
        body = {"handle": handle}
        if cursor:
            body["cursor"] = cursor
        data = requests.post(f"{API}/followers", json=body, headers=HEADERS, timeout=90).json()
        yield from data["profiles"]
        cursor = data.get("nextCursor")
        if not cursor:
            break


people = list(followers("bunjavascript", pages=3))
print(len(people), "followers, unique:", len({p["id"] for p in people}) == len(people))

On our run, three pages returned 169 unique followers. count has no effect on these endpoints; it only changes the page size on search (at most 20) and verified-followers (at most 100).

Coming from Tweepy? Tweepy wraps X’s official API, which since February 2026 is pay-per-use: $0.005 per post read and $0.010 per user or follower read (X’s pricing), and it needs an X developer account. The calls above need neither. Our Twitter scraper API comparison covers the trade-offs.


Node.js

Node 18+ has fetch built in. AbortSignal.timeout bounds each request, and a small pool fetches several accounts without a dependency:

const API = 'https://api.fetchlayer.dev/twitter';
const KEY = process.env.FETCHLAYER_API_KEY;

async function call(endpoint, body, { attempts = 3, timeoutMs = 90_000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${API}/${endpoint}`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(timeoutMs),
    });
    if (res.ok) return res.json();
    const err = await res.json();
    if (!err.retryable || attempt === attempts - 1) {
      throw new Error(`${err.code}: ${err.error} (request ${err.requestId})`);
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
  }
}

// The top tweet on a topic, and what people replied to it
const { tweets } = await call('search', { query: 'bun 1.4', product: 'Top' });
const top = tweets[0];
console.log(`@${top.author.handle}: ${top.text.slice(0, 80)}`);

const thread = await call('tweet-replies', { tweetId: top.id });
for (const reply of thread.replies.slice(0, 3)) {
  console.log(`  ↳ @${reply.author.handle}: ${reply.text.slice(0, 70)}`);
}

// Several accounts at once, at most 2 requests in flight
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  await Promise.all(
    Array.from({ length: limit }, async () => {
      while (next < items.length) {
        const i = next++;
        results[i] = await fn(items[i]);
      }
    }),
  );
  return results;
}

const handles = ['bunjavascript', 'nodejs', 'deno_land'];
const profiles = await mapLimit(handles, 2, (handle) => call('user-profile-details', { handle }));
for (const { profile } of profiles) {
  console.log(`@${profile.handle}: ${profile.followersCount} followers`);
}

Run with node tw.mjs. It printed the top tweet on the topic, three replies to it, and each account’s follower count. If you’d rather not write the HTTP layer, the @fetchlayer/twitter npm package wraps every endpoint.


TypeScript

Type the parts you rely on and check them at runtime: a TypeScript type describes the shape, it doesn’t validate it. Note that followersCount and views can be null.

export {};

interface Author {
  id: string;
  handle: string;
  name: string;
  followersCount: number | null;
  verified: boolean;
}

interface Tweet {
  id: string;
  createdAt: string;
  text: string;
  author: Author;
  counts: { likes: number; reposts: number; replies: number; views: number | null };
  url: string;
}

interface SearchResult {
  tweets: Tweet[];
  nextCursor: string | null;
}

interface ApiError {
  error: string;
  code: string;
  retryable: boolean;
  requestId: string;
}

class FetchLayerError extends Error {
  constructor(public detail: ApiError) {
    super(`${detail.code}: ${detail.error}`);
  }
}

async function call<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
  const res = await fetch(`https://api.fetchlayer.dev/twitter/${endpoint}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.FETCHLAYER_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new FetchLayerError((await res.json()) as ApiError);
  return (await res.json()) as T;
}

// Types describe the shape; check the fields you depend on at runtime.
function isTweet(value: unknown): value is Tweet {
  const t = value as Tweet;
  return typeof t?.id === 'string' && typeof t?.text === 'string' && typeof t?.author?.handle === 'string';
}

const { tweets } = await call<SearchResult>('search', { query: 'typescript 6', product: 'Latest' });
for (const t of tweets.filter(isTweet).slice(0, 5)) {
  console.log(`@${t.author.handle} (${t.author.followersCount ?? '?'}): ${t.text.slice(0, 60)}`);
}

try {
  await call('user-tweets', {});
} catch (e) {
  if (e instanceof FetchLayerError) console.log('handled:', e.detail.code, e.detail.retryable);
}

Runs as-is with Bun (bun tw.ts) and passes tsc --strict with @types/node; for Node, compile it with tsc first. The last lines catch an error as a typed FetchLayerError.


Go

The standard library is enough. A context bounds the whole run, and a sync.WaitGroup reads three timelines at once:

package main

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

type Tweet struct {
	ID     string `json:"id"`
	Text   string `json:"text"`
	URL    string `json:"url"`
	Author struct {
		Handle string `json:"handle"`
	} `json:"author"`
	Counts struct {
		Likes int  `json:"likes"`
		Views *int `json:"views"`
	} `json:"counts"`
}

type Timeline struct {
	Tweets     []Tweet `json:"tweets"`
	NextCursor *string `json:"nextCursor"`
}

type APIError struct {
	Error     string `json:"error"`
	Code      string `json:"code"`
	Retryable bool   `json:"retryable"`
	RequestID string `json:"requestId"`
}

func call(ctx context.Context, endpoint string, body any, out any) error {
	payload, _ := json.Marshal(body)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://api.fetchlayer.dev/twitter/"+endpoint, bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("FETCHLAYER_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

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

	if res.StatusCode != http.StatusOK {
		var apiErr APIError
		json.NewDecoder(res.Body).Decode(&apiErr)
		return fmt.Errorf("%s: %s (retryable=%v, request %s)", apiErr.Code, apiErr.Error, apiErr.Retryable, apiErr.RequestID)
	}
	return json.NewDecoder(res.Body).Decode(out)
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()

	handles := []string{"golang", "rustlang", "ziglang"}
	timelines := make([]Timeline, len(handles))
	errs := make([]error, len(handles))

	var wg sync.WaitGroup
	for i, handle := range handles {
		wg.Add(1)
		go func(i int, handle string) {
			defer wg.Done()
			errs[i] = call(ctx, "user-tweets", map[string]any{"handle": handle}, &timelines[i])
		}(i, handle)
	}
	wg.Wait()

	for i, handle := range handles {
		if errs[i] != nil {
			fmt.Println("@"+handle, "failed:", errs[i])
			continue
		}
		best := Tweet{}
		for _, t := range timelines[i].Tweets {
			if t.Counts.Likes > best.Counts.Likes {
				best = t
			}
		}
		fmt.Printf("@%s: %d tweets, most liked (%d): %s\n", handle, len(timelines[i].Tweets), best.Counts.Likes, best.URL)
	}
}

go run tw.go printed each account’s most-liked recent tweet. Use golang.org/x/sync/errgroup instead of the WaitGroup if you want the first error to cancel the rest.


Bun

Bun’s built-in SQLite and HTTP server make a keyword monitor one file. Run it on a schedule: INSERT OR IGNORE on the tweet id stores only what’s new.

import { Database } from 'bun:sqlite';

const db = new Database('tweets.sqlite');
db.run(`CREATE TABLE IF NOT EXISTS tweets (
  id TEXT PRIMARY KEY, handle TEXT, text TEXT, likes INTEGER, url TEXT, created_at TEXT
)`);
const insert = db.prepare(
  'INSERT OR IGNORE INTO tweets (id, handle, text, likes, url, created_at) VALUES (?, ?, ?, ?, ?, ?)',
);

async function poll(query: string) {
  const res = await fetch('https://api.fetchlayer.dev/twitter/search', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${Bun.env.FETCHLAYER_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, product: 'Latest' }),
  });
  if (!res.ok) throw new Error(JSON.stringify(await res.json()));
  const { tweets } = await res.json();
  let added = 0;
  for (const t of tweets) {
    added += insert.run(t.id, t.author.handle, t.text, t.counts.likes, t.url, t.createdAt).changes;
  }
  return added;
}

console.log('new tweets stored:', await poll('"bun install"'));

// Serve what you stored
const server = Bun.serve({
  port: 3000,
  fetch() {
    const rows = db.query('SELECT handle, text, likes, url FROM tweets ORDER BY rowid DESC LIMIT 50').all();
    return Response.json(rows);
  },
});

const rows = await (await fetch(`http://localhost:${server.port}/`)).json();
console.log('served', rows.length, 'rows; first from @' + rows[0]?.handle);
server.stop();

On our run it stored 20 new tweets and served them back as JSON.


What it costs

Every language costs the same: $1.99 per 1,000 requests pay-as-you-go, less on monthly plans, and the first 30 requests are free (pricing). A request returns a page of results, so a timeline page of about 25 tweets costs one request. On X’s own API, 25 post reads cost $0.125. Failed requests are never billed.