+ Integration Guide

· Updated August 28, 2026

Twitter/X API with Bun: Complete Guide

How to scrape Twitter/X using Bun and the FetchLayer API. Fast, lightweight examples for searching tweets, fetching profiles, and pulling follower data.

Written by Alex P.

  • Bun
  • twitter scraping
  • X API
  • Twitter API
  • JavaScript
  • API integration

Bun is a fast JavaScript runtime that’s great for API scripts and data pipelines. This guide shows how to use FetchLayer’s Twitter/X API with Bun — same API, faster runtime.


Setup

  1. Get a free API key (no credit card)
  2. Install Bun if you haven’t: curl -fsSL https://bun.sh/install | bash
  3. No npm packages needed — Bun has fetch built in.

API Client

const API_KEY = Bun.env.FETCHLAYER_API_KEY!;
const BASE = 'https://api.fetchlayer.dev/twitter';

async function twitter(endpoint: string, body: Record<string, unknown>) {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

Search Twitter/X

const data = await twitter('search', {
  query: 'best bun packages 2026',
  product: 'Latest',
  count: 10,
});

for (const tweet of data.results) {
  console.log(`[${tweet.likeCount} likes] @${tweet.author.handle}: ${tweet.text.slice(0, 100)}`);
}

Run it:

FETCHLAYER_API_KEY=ss-your-key bun run search.ts

Get a Tweet by ID

const tweet = await twitter('tweet-detail', {
  tweetId: '1942939879222220800',
});

console.log(`@${tweet.author.handle}: ${tweet.text}`);
console.log(`${tweet.likeCount} likes · ${tweet.retweetCount} retweets · ${tweet.replyCount} replies`);

Get a User Profile

const profile = await twitter('user-profile-details', {
  handle: 'openai',
});

console.log(`${profile.displayName} (@${profile.handle})`);
console.log(`Followers: ${profile.followersCount.toLocaleString()}`);
console.log(`Joined: ${profile.joinedAt}`);

Get a User’s Tweets

const data = await twitter('user-tweets', {
  handle: 'rauchg',
  count: 20,
});

for (const tweet of data.tweets || []) {
  console.log(`[${tweet.likeCount || 0}] ${tweet.text.slice(0, 120)}`);
}

Full Example: Twitter Keyword Monitor

Save as monitor.ts:

const API_KEY = Bun.env.FETCHLAYER_API_KEY!;
const BASE = 'https://api.fetchlayer.dev/twitter';

async function twitter(endpoint: string, body: Record<string, unknown>) {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

const keyword = Bun.argv[2] ?? 'your-product';

console.log(`\nSearching Twitter/X for "${keyword}"...\n`);

const data = await twitter('search', {
  query: keyword,
  product: 'Latest',
  count: 25,
});

const results = data.results || [];
console.log(`Found ${results.length} tweets\n`);

const top = results
  .sort((a: any, b: any) => (b.likeCount || 0) - (a.likeCount || 0))
  .slice(0, 5);

for (const tweet of top) {
  console.log(`[${tweet.likeCount} likes] @${tweet.author.handle}: ${tweet.text.slice(0, 120)}`);
  console.log(`  ${tweet.url}\n`);
}

process.exit(0);

Run it:

FETCHLAYER_API_KEY=ss-your-key bun run monitor.ts "react server components"

Bun-Specific Tips

Use Bun.env instead of process.env — it’s faster and type-aware.

Write results to a file:

const data = await twitter('search', { query: 'bun runtime', product: 'Top' });
await Bun.write('results.json', JSON.stringify(data, null, 2));

Run on a schedule with cron:

# Add to crontab: every hour
0 * * * * cd /path/to/project && FETCHLAYER_API_KEY=ss-... bun run monitor.ts >> log.txt 2>&1

Handling Errors and Rate Limits

The client above throws a generic error for any non-OK response, which is fine for a script you’re watching, but a background job needs to tell a 429 apart from a 401. 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:

async function twitter(endpoint: string, body: Record<string, unknown>, retries = 2): Promise<any> {
  for (let attempt = 0; attempt <= retries; attempt++) {
    const res = await fetch(`${BASE}/${endpoint}`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    if (res.status === 429) {
      if (attempt === retries) throw new Error('Rate limited — back off and try again later');
      const wait = Number(res.headers.get('Retry-After') ?? 5);
      await Bun.sleep(wait * 1000);
      continue;
    }
    if (res.status === 401) throw new Error('Invalid or missing API key');
    if (res.status === 400) throw new Error(`Bad request to /${endpoint}: ${await res.text()}`);
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

    return res.json();
  }
}

A 429 mid-run is expected behavior on the free tier, not a bug — the retry above respects Retry-After (via Bun.sleep) rather than hammering the endpoint again immediately.


Typing tweets and profiles

Bun executes TypeScript directly, so you get typed responses without a build step. The Twitter/X endpoints return a nested author object on every tweet, which is the part worth modelling properly:

interface TwitterUser {
  handle: string;
  displayName: string;
  followersCount: number;
  followingCount: number;
  verified?: boolean;
  joinedAt?: string;
}

interface Tweet {
  id: string;
  text: string;
  author: TwitterUser;
  likeCount: number;
  retweetCount: number;
  replyCount: number;
  createdAt: string;
  url: string;
}

interface SearchResponse { results: Tweet[] }
interface UserTweetsResponse { tweets: Tweet[] }

async function twitter<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

Response keys differ per endpoint — search returns results, user-tweets returns tweets, and tweet-detail returns a single tweet rather than a wrapper. Separate interfaces make those mismatches compile errors instead of runtime undefineds.


Tracking follower changes over time

Follower counts are a single number, which makes them cheap to poll and easy to store. Bun’s built-in SQLite makes a follower-history table trivial — no native module, no install:

import { Database } from 'bun:sqlite';

const db = new Database('twitter.sqlite');
db.run(`
  CREATE TABLE IF NOT EXISTS follower_history (
    handle     TEXT NOT NULL,
    followers  INTEGER NOT NULL,
    checked_at INTEGER NOT NULL,
    PRIMARY KEY (handle, checked_at)
  )
`);

const record = db.prepare(`
  INSERT INTO follower_history (handle, followers, checked_at)
  VALUES ($handle, $followers, $checked_at)
`);

async function snapshot(handles: string[]) {
  for (const handle of handles) {
    const profile = await twitter<TwitterUser>('user-profile-details', { handle });
    record.run({
      $handle: handle,
      $followers: profile.followersCount,
      $checked_at: Date.now(),
    });
  }
}

function growthSince(handle: string, sinceMs: number) {
  const rows = db.query<{ followers: number; checked_at: number }, [string, number]>(
    `SELECT followers, checked_at FROM follower_history
     WHERE handle = ? AND checked_at >= ?
     ORDER BY checked_at ASC`,
  ).all(handle, sinceMs);

  if (rows.length < 2) return null;
  const first = rows[0];
  const last = rows[rows.length - 1];
  return {
    delta: last.followers - first.followers,
    pct: ((last.followers - first.followers) / first.followers) * 100,
    days: (last.checked_at - first.checked_at) / 86_400_000,
  };
}

Run snapshot() daily from cron and you have a competitor-growth series that no single API call can give you — the endpoint returns the current count, the history is something you accumulate.


Paginating follower lists

Follower and following lists are the expensive endpoints here, because they page. Each page is a billed request, so decide up front how deep you actually need to go rather than looping until exhaustion:

interface FollowersResponse {
  followers: TwitterUser[];
  nextCursor?: string;
}

async function collectFollowers(handle: string, maxPages = 5) {
  const collected: TwitterUser[] = [];
  let cursor: string | undefined;

  for (let page = 0; page < maxPages; page++) {
    const data = await twitter<FollowersResponse>('user-followers', {
      handle,
      ...(cursor ? { cursor } : {}),
    });

    collected.push(...(data.followers ?? []));

    // No cursor back means there are no further pages.
    if (!data.nextCursor) break;
    cursor = data.nextCursor;
  }

  return collected;
}

The maxPages cap is the important part. An account with 200,000 followers will happily return cursors until you’ve spent your monthly quota on one profile. Cap it, and sample rather than exhaust — for most analysis (identifying notable followers, checking overlap between two accounts) the first few pages are sufficient.


Serving results over HTTP

Bun.serve() turns the stored history into a JSON endpoint without pulling in a framework:

Bun.serve({
  port: 3000,
  routes: {
    '/api/growth/:handle': (req) => {
      const thirtyDaysAgo = Date.now() - 30 * 86_400_000;
      return Response.json(growthSince(req.params.handle, thirtyDaysAgo));
    },
  },
});

Cron writes snapshots, SQLite accumulates them, Bun.serve() reads them back — three built-ins and no dependencies.


What’s Next