Twitter API in Node.js: a 2026 guide with working fetch code
Node.js reads Twitter/X data with its built-in fetch: send an HTTPS request with an API key in a header, parse the JSON, and pass the returned cursor back for the next page. The official X API charges $0.005 per post read.
Short answer: Node.js needs no package to read X data: fetch is built in. The official X API, even through twitter-api-v2, needs a developer account and prepaid credits: $0.005 per post, $0.015 with its author from November 22, 2026. For public data, use Sorsa API: one ApiKey header, 20 tweets per request from $0.07 per 1,000; 100 free requests fetch 2,000.
- Endpoint
POST /search-tweets- Page size
- 20 tweets per request
- Price
- from $0.07 per 1,000 tweets
#Which npm package works with the official X API in 2026?
Two packages call the official X API from Node.js. twitter-api-v2, a community client, reached version 1.31.0 on October 25, 2026 and is installed about 336K times a week. X's own TypeScript SDK, @xdevplatform/xdk, is at 0.6.6. Both wrap the same paid platform, so neither works without a developer account, a bearer token and prepaid credits. The same requests in Python, with Tweepy and xdk in that role, are in the guide to the Twitter API in Python.
The credits are the part older Node tutorials skip. X has no free tier for new developers and bills every object a response returns: $0.005 per post and $0.010 per user, up to 3M post reads a month (X's price table (opens in a new tab)). From November 22, 2026, authors and media pulled in through expansions cost the same as direct reads (X's notice of October 23 (opens in a new tab)). A typical twitter-api-v2 search then pays for every author it attaches:
import { TwitterApi } from "twitter-api-v2";
const client = new TwitterApi("YOUR_BEARER_TOKEN").readOnly;
const page = await client.v2.search("nodejs lang:en", {
max_results: 100,
expansions: ["author_id"],
});
// 100 posts: $0.50. From Nov 22, 2026, with authors: up to $1.50
Its paginators add a second trap: the library's own docs say a for await loop over a paginator keeps fetching until the results or your rate limit run out, and every page is billed. Every rate, cap and date is laid out in X API pricing.
What the official route needs before the first call
- A developer account at console.x.com, opened with an X account that accepts X's Developer Agreement.
- An app, whose bearer token X shows only once.
- Credits bought in advance and spent per post, user and media object returned.
- OAuth user tokens for anything that posts, likes or follows.
Reading public posts and profiles needs none of that. An X data API with its own key answers plain HTTPS, and Node's fetch is the whole client.
#Get tweets in Node.js with one API key
To read X data from Node.js, use Sorsa API, a Twitter (X) API and scraper: one ApiKey header instead of OAuth, 20 requests per second on every plan, free accounts included, and search results from $0.07 per 1,000 tweets. Every tweet arrives with its author's full profile, so code sets no expansions or field lists. Sorsa API never asks for an X login, so no X account of yours carries any risk.
Set up the key and send the first request
-
Get a key. Sign up and copy your API key from the dashboard. The first 100 requests are free, need no card and never expire.
-
Check Node and store the key. Run
node -v: the LTS lines in November 2026 are 22, 24 and 26, and all three havefetchand--env-filebuilt in. Put the key in a.envfile that git ignores, and call the API only from server code.Terminal node -v # v22 or newer echo 'SORSA_API_KEY=your_key' > .env echo '.env' >> .gitignore -
Search for tweets. Save this as
first.mjsand runnode --env-file=.env first.mjs. It sends a query in X's search syntax toPOST /search-tweets(opens in a new tab) with the key in theApiKeyheader; the.mjsextension turns on top-levelawait.const API = "https://api.sorsa.io/v3"; const KEY = process.env.SORSA_API_KEY ?? "YOUR_API_KEY"; const res = await fetch(`${API}/search-tweets`, { method: "POST", headers: { ApiKey: KEY, "Content-Type": "application/json" }, body: JSON.stringify({ query: "nodejs lang:en", order: "latest" }), }); if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).message}`); const { tweets, next_cursor } = await res.json(); for (const t of tweets) { console.log(t.user.username, t.full_text.slice(0, 60)); } console.log("next page:", next_cursor); -
Read the page. Each response holds up to 20 tweets and a
next_cursorfor the next page. The values below are demo data in the documented shape.Response200 OK { "tweets": [ { "id": "1985432176540981234", "full_text": "Moved our nightly X report to one Node script.", "created_at": "2026-11-01T18:42:07Z", "lang": "en", "likes_count": 58, "retweet_count": 9, "reply_count": 4, "quote_count": 1, "view_count": 4920, "bookmark_count": 6, "is_reply": false, "user": { "id": "1592041833471827968", "username": "node_stream_digest", "display_name": "Node Stream Digest", "followers_count": 2310, "verified": false } } ], "next_cursor": "DAABCgABF7Y8kQ3wAAA" }
Every field below comes straight from the response; the JavaScript type is what res.json() gives you, and IDs stay strings on purpose.
| Field | JavaScript type | What it holds |
|---|---|---|
id | string | The tweet's 19-digit ID: never convert it to a Number |
full_text | string | The whole text of the post |
created_at | string | ISO 8601 in UTC: new Date(t.created_at) parses it |
likes_count, retweet_count, reply_count, quote_count | number | Engagement counts |
view_count, bookmark_count | number | Views and bookmarks |
user | object | The author's profile: 19 fields such as username and followers_count |
quoted_status, retweeted_status | object or absent | The quoted or reposted tweet, in the same shape |
id- JavaScript type
- string
- What it holds
- The tweet's 19-digit ID: never convert it to a Number
full_text- JavaScript type
- string
- What it holds
- The whole text of the post
created_at- JavaScript type
- string
- What it holds
- ISO 8601 in UTC:
new Date(t.created_at)parses it
likes_count,retweet_count,reply_count,quote_count- JavaScript type
- number
- What it holds
- Engagement counts
view_count,bookmark_count- JavaScript type
- number
- What it holds
- Views and bookmarks
user- JavaScript type
- object
- What it holds
- The author's profile: 19 fields such as
usernameandfollowers_count
quoted_status,retweeted_status- JavaScript type
- object or absent
- What it holds
- The quoted or reposted tweet, in the same shape
#Retry 429s and server errors in one helper
Put every call behind one function that retries only what is worth retrying. A 429 means more than 20 requests started within one second on your key; a 5xx or a timeout is a passing fault. The rest of this guide imports this sorsa helper.
// sorsa.mjs: one place for the key, timeouts and retries
const API = "https://api.sorsa.io/v3";
const KEY = process.env.SORSA_API_KEY ?? "YOUR_API_KEY";
const RETRY = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
export async function sorsa(path, { body, query } = {}) {
const url = new URL(API + path);
if (query) url.search = query; // a URLSearchParams for GET endpoints
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, {
method: body ? "POST" : "GET",
headers: { ApiKey: KEY, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (err) { // timeout or dropped connection
if (attempt === 4) throw err;
await sleep(1000 * 2 ** attempt);
continue;
}
if (res.ok) return res.json();
if (!RETRY.has(res.status) || attempt === 4) {
const { message } = await res.json().catch(() => ({}));
throw new Error(`${res.status} on ${path}: ${message}`);
}
await sleep(1000 * 2 ** attempt); // 1, 2, 4, 8 seconds
}
}
A 401 or 403 never earns a retry: the key is wrong or missing, or its requests are used up, and the message field says which. Failed requests are never charged, so a retry costs nothing. Every Sorsa API endpoint only reads, so repeating a POST such as /search-tweets can't create anything twice. Sorsa API sends no rate-limit headers because the window resets every second: the first one-second wait clears a 429.
#Page through results with next_cursor
A cursor is an opaque key to the next page: send next_cursor back exactly as it came and stop when a response has none. An async generator fits this loop well, because the code that reads the pages never sees the cursor at all.
import { sorsa } from "./sorsa.mjs";
export async function* pages(path, body, maxPages = 100) {
for (let n = 0; n < maxPages; n++) {
const page = await sorsa(path, { body });
yield page;
if (!page.next_cursor) return; // null, empty or absent: the end
body = { ...body, next_cursor: page.next_cursor };
}
}
import { pages } from "./pages.mjs";
const search = { query: "nodejs lang:en", order: "latest" };
for await (const page of pages("/search-tweets", search, 50)) {
for (const t of page.tweets) console.log(t.id, t.created_at);
}
Search and timelines return up to 20 tweets a page, and a page can hold fewer while more remain, so the count never tells you the end. Keep the query and order unchanged along one cursor, and treat maxPages as a budget: 50 pages are 50 requests, whatever the query matches. GET endpoints take the cursor in the query string instead: GET /followers (opens in a new tab) returns up to 200 profiles a page, and query.set("next_cursor", page.next_cursor) moves it on. POST /user-tweets (opens in a new tab) works with the same generator; to download all tweets from a user with a resumable cursor, that guide has the full script.
#Fetch 100 tweets or profiles per request
When you already hold IDs or usernames, send them in batches. POST /tweet-info-bulk (opens in a new tab) takes up to 100 tweet IDs or links in tweet_links, and GET /info-batch (opens in a new tab) takes up to 100 usernames; each batch is one request.
import { sorsa } from "./sorsa.mjs";
// tweetIds and usernames: arrays of strings you already hold
const chunks = (list, size = 100) =>
Array.from({ length: Math.ceil(list.length / size) },
(_, i) => list.slice(i * size, (i + 1) * size));
const tweets = [];
for (const ids of chunks(tweetIds)) {
const body = { tweet_links: ids }; // up to 100 IDs or links
tweets.push(...(await sorsa("/tweet-info-bulk", { body })).tweets);
}
const users = [];
for (const names of chunks(usernames)) {
const query = new URLSearchParams();
for (const name of names) query.append("usernames", name);
users.push(...(await sorsa("/info-batch", { query })).users);
}
GET /info-batch reads usernames as a repeated parameter (usernames=a&usernames=b), so build it with append. Passing an array to new URLSearchParams({ usernames }) joins the names with commas into one value instead. A request costs the same whatever it returns, so a batch of 100 tweets costs what one tweet costs: from $0.014 per 1,000 tweets, more than 250x less than the official X API's $5.00. Batch responses carry no cursor; the whole batch is in tweets or users.
#Run 20 requests per second from one key
Sorsa API allows 20 requests per second per key on every plan, and one Node process reaches that easily: the event loop keeps dozens of requests in flight on a single thread. The limit to respect is the start rate, so put one pacer in front of every call and split the work into independent chains. One cursor chain can't run in parallel, because each page needs the cursor of the page before, so run one chain per account or one per day of a search.
import { sorsa } from "./sorsa.mjs";
// Hands out start times 1/19 s apart, however many lanes are waiting.
function pacer(perSecond) {
const gap = 1000 / perSecond;
let next = 0;
return () => {
const now = Date.now();
const start = Math.max(next, now);
next = start + gap;
return new Promise((ok) => setTimeout(ok, start - now));
};
}
const slot = pacer(19); // one pacer per key, one start under the limit
async function lane(query, maxPages = 100) {
const tweets = [];
let body = { query };
for (let n = 0; n < maxPages; n++) {
await slot();
const page = await sorsa("/search-tweets", { body });
tweets.push(...page.tweets);
if (!page.next_cursor) break;
body = { ...body, next_cursor: page.next_cursor };
}
return tweets;
}
const day = (d) => new Date(Date.UTC(2026, 9, d)).toISOString().slice(0, 10);
const queries = Array.from({ length: 31 }, (_, i) =>
`nodejs lang:en since:${day(i + 1)} until:${day(i + 2)}`); // Oct 1-31
const results = await Promise.all(queries.map((q) => lane(q)));
console.log(results.flat().length, "tweets");
The pacer needs no lock: JavaScript runs one callback at a time, so two lanes can never take the same start time. At 19 starts a second it leaves one start of headroom, because timer jitter can push the 20th start into the next window. The helper's retry catches any 429 that still slips through. Promise.all without a pacer is the common mistake: 60 calls fired at once put 40 straight into 429s and spend seconds in back-off.
Serverless platforms break that rule quietly. Every function instance on Lambda, Vercel or Cloud Run loads its own copy of lanes.mjs. Three warm instances therefore run three pacers on one key and start up to 57 requests a second between them. Give each instance a share of the rate (pacer(6) for three), or run the bulk job in one worker. The limit is counted per key, so a second key for a second worker brings its own 20 a second. At 20 requests a second, batches of 100 move up to 2,000 tweets a second and search pages 400.
#Save tweets to JSONL or CSV
Write each page as it arrives instead of holding every tweet in memory. JSON Lines, one JSON object per line, keeps every field and appends safely; CSV opens in Excel and Google Sheets. Both streams below wait for drain when the disk falls behind.
import { createWriteStream } from "node:fs";
import { once } from "node:events";
import { pages } from "./pages.mjs";
const jsonl = createWriteStream("tweets.jsonl");
const csv = createWriteStream("tweets.csv");
const put = async (out, line) => {
if (!out.write(line)) await once(out, "drain"); // respect backpressure
};
const cell = (v) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const BOM = String.fromCharCode(0xfeff); // tells Excel the file is UTF-8
await put(csv, BOM + "id,created_at,username,likes_count,full_text\r\n");
const search = { query: "nodejs lang:en" };
for await (const page of pages("/search-tweets", search, 500)) {
for (const t of page.tweets) {
await put(jsonl, JSON.stringify(t) + "\n");
const row = [t.id, t.created_at, t.user.username, t.likes_count,
t.full_text];
await put(csv, row.map(cell).join(",") + "\r\n");
}
}
jsonl.end();
csv.end();
The byte order mark at the start of the CSV tells Excel the file is UTF-8, so emoji and accents show correctly. Every cell is quoted, so commas, quotes and line breaks inside a tweet stay in their column. Five hundred pages make up to 10K tweets for 500 requests; memory stays flat however long the run is.
Good to know
JavaScript numbers hold integers exactly only up to 2^53, about 9 quadrillion, and tweet IDs have 19 digits.
Number("1985432176540981234")returns 1985432176540981200, a different tweet. Sorsa API sends every ID and cursor as a string, so keep them strings, compare them as strings, and useBigInt(id)when you need to sort by ID.
#Add TypeScript types for the responses
TypeScript users get type safety from a few interfaces named exactly as the API's fields. The ones below follow the live OpenAPI reference and cover the fields most code reads; the reference lists the rest.
// sorsa-types.ts: field names as in the Sorsa API reference
export interface SorsaUser {
id: string; // 64-bit ID: keep it a string
username: string;
display_name: string;
description: string;
followers_count: number;
followings_count: number;
tweets_count: number;
verified: boolean;
protected: boolean;
created_at: string; // ISO 8601
}
export interface SorsaTweet {
id: string;
full_text: string;
created_at: string;
lang: string;
likes_count: number;
retweet_count: number;
reply_count: number;
quote_count: number;
view_count: number;
bookmark_count: number;
is_reply: boolean;
user: SorsaUser;
quoted_status?: SorsaTweet;
retweeted_status?: SorsaTweet;
}
export interface TweetsPage {
tweets: SorsaTweet[];
next_cursor?: string | null;
}
Optional fields carry a ?: a quoted or reposted tweet exists only on quotes and reposts, so the compiler makes you check before you read it. Cast at the boundary, const page = (await sorsa(path, { body })) as TweetsPage, and the rest of the code is checked. Node 22.18 and later run .ts files directly by stripping the types, so node --env-file=.env job.ts works without a build step. For every endpoint at once, an OpenAPI generator such as openapi-typescript can turn the full reference into types.
#Can you call a Twitter API from browser JavaScript?
No: an API key in browser code is readable by anyone who opens the developer tools, and Sorsa API is built for server-side calls, so browsers get CORS errors. Put a small route on your server, keep the key there, and let the front end call your route.
// server.mjs: npm install express
import express from "express";
import { sorsa } from "./sorsa.mjs";
const app = express();
app.get("/api/tweets", async (req, res) => {
const query = String(req.query.q ?? "").slice(0, 200);
const page = await sorsa("/search-tweets", { body: { query } });
res.json(page.tweets.map((t) => ({ id: t.id, text: t.full_text })));
});
app.listen(3000);
The browser then runs fetch("/api/tweets?q=nodejs") against your own domain and never sees the key. The same pattern fits any server runtime: a Cloudflare Worker or a Lambda function keeps the key in its environment variables, and the page only ever talks to it. Cache the route's answer for a minute when many visitors ask the same thing: Sorsa API returns fresh data on every request, and one cached answer serves them all for one request.
#What a Node.js job costs
Sorsa API charges per request, whatever the endpoint returns: search results cost from $0.07 per 1,000 tweets, batches from $0.014, and failed requests are never charged. The official X API charges per object instead: $5.00 per 1,000 posts, and from November 22, 2026 up to $15.00 per 1,000 when the code expands each post's author, as most twitter-api-v2 searches do.
Official X API
$5,000
for 1M tweets
$0.005 per post
Sorsa API
from$70
for 1M tweets
from $0.07 per 1,000
Each tweet carries its author's profile
The builder below writes the Node.js loop for a job and shows its requests, the fastest time on one key and the cost at Sorsa API's lowest rate.
// sorsa() and pages() come from the helpers above
const body = { query: "nodejs lang:en" };
for await (const page of pages("/search-tweets", body, 5000)) {
save(page.tweets); // up to 20 tweets a page
}
- Requests
- 5,000
- Fastest on one key
- about 4 min
- Sorsa API
- from $7
Time at 20 requests a second; cost at $1.40 per 1,000 requests. One search is one cursor chain, so split it into date windows to run lanes side by side.
The time is the fastest one key allows; a single cursor chain runs at the pace of its round trips, so split big jobs into lanes.
The rate per 1,000 requests runs from $4.90 on Starter to $1.40 on Hyperscale, and every endpoint uses the same rate. Checking what is left costs nothing: GET /key-usage-info (opens in a new tab) never counts against your requests.
import { sorsa } from "./sorsa.mjs";
const usage = await sorsa("/key-usage-info");
console.log(usage.remaining_requests, "left until", usage.valid_until);
#Moving twitter-api-v2 code to Sorsa API
Most twitter-api-v2 calls map to one Sorsa API endpoint, and the code loses its expansions and field lists, because every tweet already carries its author and counts. The table pairs the common client.v2 methods with their endpoints; to migrate from the Twitter/X API across a whole codebase, that guide maps every v2 endpoint and response field.
| twitter-api-v2 method | Endpoint | What changes |
|---|---|---|
search | POST /search-tweets | Any date back to 2006, not only the last 7 days |
searchAll | POST /search-tweets | The same endpoint with since: and until: in the query |
userTimeline | POST /user-tweets | A username is enough, and there is no 3,200-post cap |
userByUsername | GET /info (opens in a new tab) | Every profile field, no user.fields |
usersByUsernames | GET /info-batch | 100 usernames per request |
tweets | POST /tweet-info-bulk | 100 IDs or links per request |
followers | GET /followers | 200 followers per page |
tweetRetweetedBy | POST /retweeters (opens in a new tab) | The same cursor loop |
tweet | none | Read-only: post through the official X API |
search- Endpoint
POST /search-tweets- What changes
- Any date back to 2006, not only the last 7 days
searchAll- Endpoint
POST /search-tweets- What changes
- The same endpoint with
since:anduntil:in the query
userTimeline- Endpoint
POST /user-tweets- What changes
- A username is enough, and there is no 3,200-post cap
userByUsername- Endpoint
GET /info(opens in a new tab)- What changes
- Every profile field, no
user.fields
usersByUsernames- Endpoint
GET /info-batch- What changes
- 100 usernames per request
tweets- Endpoint
POST /tweet-info-bulk- What changes
- 100 IDs or links per request
followers- Endpoint
GET /followers- What changes
- 200 followers per page
tweetRetweetedBy- What changes
- The same cursor loop
tweet- Endpoint
- none
- What changes
- Read-only: post through the official X API
Field names change as well: text becomes full_text, public_metrics.like_count becomes likes_count, author_id with includes.users becomes the user object, and meta.next_token becomes next_cursor. The paginator's fetchNext() becomes the pages generator above, which stops at your page budget instead of at a rate limit. POST /user-tweets also reaches past the newest 3,200 posts, where the official timeline stops (X's timeline docs (opens in a new tab)), and recent search on the official X API covers only the last 7 days (X's search docs (opens in a new tab)).
#Run your first Node.js request today
Reading X data from Node.js takes fetch, one key and four small pieces: sorsa for retries, pages for cursors, chunks for batches of 100 and a pacer for 20 requests a second. Sorsa API is the Twitter API to start with for this: 20 tweets per search request from $0.07 per 1,000, the author inside every tweet, search back to 2006, and 100 free requests that need no card and never expire. To pull the same data into a workflow without code, see the Twitter API in n8n.
#FAQ
Which Twitter API should I use with Node.js?
For reading X data from Node.js, use Sorsa API: one key in an ApiKey header with the built-in fetch, search results from $0.07 per 1,000 tweets and batches from $0.014, 20 requests per second on every plan, search back to 2006, and no X login, so your account is never at risk. To post as well, pair it with the official X API.
Is there an official Twitter API SDK for Node.js?
Yes. X publishes a TypeScript SDK, @xdevplatform/xdk (version 0.6.6), which works in Node.js and bundlers. It is a client for the official X API, so every call still needs a developer account, a bearer token and prepaid credits at $0.005 per post. Sorsa API needs no SDK: plain fetch covers all 45 of its endpoints.
What is the best npm package for Twitter/X data?
For Sorsa API, none: Node's built-in fetch and the 40-line helpers in this guide cover every endpoint, with no dependency to update. For the official X API, twitter-api-v2 is the most installed client, at about 336K downloads a week and version 1.31.0, with X's own @xdevplatform/xdk beside it; both bill your X credits per object.
Is there a free npm package to get tweets without an API key?
Packages such as rettiwt-api read X's web app instead of an API. Without a login they reach only a few lookups; for the rest they send your X account's cookies with every request. That ties the traffic to your account, and they break when X changes its web app. Sorsa API never asks for your X login, and its 100 free requests need no card. The trade-offs are in how to scrape Twitter.
Can I post tweets from Node.js with Sorsa API?
No. Sorsa API is read-only by design: it reads public posts, profiles and followers and never posts, likes, follows or sends messages, so a key can't act on any X account. To post from Node.js, use the official X API with an OAuth user token, for example twitter-api-v2's client.v2.tweet at $0.015 a post, and keep Sorsa API for the reads.
Can I get Twitter data in Node.js without a developer account?
Yes. Sorsa API needs only its own key: sign up, set the ApiKey header and call any of its endpoints from Node.js, with no X developer account, app review or OAuth. The first 100 requests are free and fetch up to 2,000 tweets by search or 10K in batches of 100. The options are compared in Twitter API without a developer account.
#How we checked
We wrote every request from Sorsa API's live OpenAPI reference (45 endpoints) and its docs on authentication, rate limits and pagination, read on November 2, 2026. We had no Sorsa API key for this pass, so the response uses demo values. We ran the Node.js blocks on Node 26 against a local mock that answers in the documented shapes: the first request, the retry helper (two 503s, then a 60-call burst), the cursor generator, batches of 100, the JSONL and CSV export, the key-usage call and the pacer. At 19 starts a second the pacer ran 31 lanes, 93 requests, without a 429 in three runs; at exactly 20, one run of three sent 29 requests to the retry. The Express, Next.js and twitter-api-v2 snippets were not run. The same day we read X's prices, its November 22 notice on expansions and its tools page on docs.x.com, the npm registry entries of twitter-api-v2 and @xdevplatform/xdk, and Node.js's release table.
Sources
- X API pay-per-usage pricing (docs.x.com) (opens in a new tab)
- X API PPU: expansions update (X Developers, October 23, 2026) (opens in a new tab)
- Tools and libraries: the official TypeScript SDK (docs.x.com) (opens in a new tab)
- Timelines (docs.x.com) (opens in a new tab)
- Search Posts (docs.x.com) (opens in a new tab)
- twitter-api-v2 on npm (opens in a new tab)
- @xdevplatform/xdk on npm (opens in a new tab)
- Node.js releases (opens in a new tab)
- Sorsa API reference, OpenAPI
- Sorsa API rate limits (opens in a new tab)
- Sorsa API pagination (opens in a new tab)