X's API searches posts, but its recent search looks back only 7 days, and full-archive search is a separate endpoint with its own access rules. ToolzerHub's X API reached back to 2014 in our runs, but one query stops on its own after roughly 900 to 2,100 posts, and on a busy keyword latest covered only three days (artemis: 1,609 posts). So you slice a long job into date windows. It is 5 credits a call, about 18 posts a call. We read X's documentation and ran the searches on 2026-10-17.
What X's two search endpoints cover
X documents two search endpoints. Its Search Posts page says recent search covers "Posts from the last 7 days. Available to all developers", and full-archive search covers "the complete archive back to 2006. Available to pay-per-use and Enterprise customers."
| Recent search | Full-archive search | |
|---|---|---|
| Path | /2/tweets/search/recent |
/2/tweets/search/all |
| Reach | last 7 days | back to March 2006 |
| Posts per request | 100 max | 500 max |
| Query length | 512 chars | 1,024 chars |
| Rate limit, per app | 450 / 15 min | 1/sec, 300 / 15 min |
The limits come from X's rate-limit table. Dates are start_time and end_time parameters, not query operators. X's operators include from:, to:, lang:, has:images and min_likes:. Its changelog (2026-05-04) says retweets "are no longer returned in keyword-based search results".
What X's pages leave unclear
One point conflicts, and two are easy to miss.
- Who may use full-archive search. The Search Posts page says pay-per-use and Enterprise. The quickstart says it "requires Self-serve or Enterprise access". We have no X developer account, so we could not test which is true for a new account.
- Query length. 512 characters on recent and 1,024 on full-archive for self-serve; 4,096 is the Enterprise limit, which is the ceiling the reference schema shows.
- Default window. The quickstart says "By default, results return Posts from the last 30 days", so reaching 2020 needs
start_timeandend_time.
What X charges for search
X's pricing page lists "Posts: Read" at "$0.005 per resource" and has no search row, so we treat each post a search returns as one Post read; the free-tier post covers the rest of X's prices. Counts, which return a number instead of posts, are $0.005 per request for recent and $0.010 for all.
So 1,000 matching posts cost $5.00 on X: 10 requests at 100 on recent search, or 2 at 500 on full-archive. X warns that a broad query "will match a massive volume of Posts and quickly consume your usage limits".
Search posts with ToolzerHub
We make ToolzerHub. GET /v1/x/search takes query, sort (latest or top) and cursor, and costs 5 credits a call (our OpenAPI file, 2026-10-17). Dates go inside the query, as in from:NASA since:2020-01-01 until:2020-02-01. No X developer account is needed.
All 197 calls the route took in the 7 days before were ours, so that count says nothing about customer traffic: 195 returned 200 and 2 returned 400, with no 5xx. We made 10 live calls with different queries (a keyword, from:NASA, a date window, min_likes:500, a hashtag, sort=top) and 5 repeats of one query. All 15 returned 200 with posts, and the repeats returned the same 16 posts each time. A page held 13 to 20 posts; the median call took 1.0 to 1.4 seconds.
On one page each, from:github min_likes:500, lang:ja, a #hashtag, has:images, to:NASA and -is:retweet returned 20 of 20 matching posts. Complex queries were looser: moon OR mars lang:en returned 20 posts but only 10 had either word in the text, and -has:media returned 0 posts, even on artemis, while -has:links worked. Check a page before you page it.
How deep one query goes
A query ends when has_more turns false. The point it ends at varies. All numbers are from 2026-10-17, with no duplicate ids in any run.
| Query | Sort | Unique posts | Oldest post | Ended |
|---|---|---|---|---|
from:NASA |
latest | 1,060 | 2025-09-10 | yes, 54 calls |
sourdough |
top | 906 | 2014-06-11 | yes, 58 calls |
sourdough |
latest | 2,025 | 2026-09-20 | yes, 114 calls |
from:SpaceX |
latest | 2,042 | 2023-11-11 | yes, 111 calls |
artemis |
latest | 1,609 | 2026-10-14 | no, our cap of 100 calls |
So the end is not a fixed 1,000. Account queries ended at 1,060 (NASA) and 2,042 (SpaceX), a top-sorted keyword at 906, and sourdough by date at 2,025. artemis was still going at 1,609 when we stopped, and those posts span three days. sort=top reaches older posts (back to 2014) but fewer.
Reach further back with date windows
from:NASA unsliced ended at 2025-09-10, yet month windows returned NASA posts for January to March 2020: 272, 248 and 240, 760 in all. Fourteen month windows from 2025-09 to 2026-10 found 1,097 posts in 75 calls. The unsliced query found 1,060 back to 2025-09-10; the windows found 1,088 dated 2025-09-10 or later, 28 more. October 2025 returned 0 posts, and we could not confirm why: three NASA accounts returned nothing for October 2025 and from:NASA nothing until 2025-11-13, while from:SpaceX for October returned a full page.
Window edges are not UTC midnight. until: appears to include its end day, and from:NASA since:2020-01-01 until:2020-01-02 returned posts stamped from 2020-01-01T05:01:04Z to 2020-01-03T02:00:01Z. The script ends each window on its last day and keeps one row per post id.
A script that pages date windows to a CSV
The script appends since: and until: to your query, pages each window to its end or a page cap, and writes one row per post. It leaves the author out. Put from: in the query if you want one account.
import argparse
import csv
import os
import sys
import time
from datetime import date, timedelta
import requests
BASE = "https://api.toolzerhub.com/v1/x/search"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["id", "created_at", "url", "likes", "reposts", "replies", "quotes", "views", "lang", "text"]
calls = 0
BAD = object() # a 400: skip this window
GAVE_UP = object() # a 5xx or a 429/503 that kept coming back: skip this window
def cell(value):
# Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + value
return value
def get(params, tries=5):
# 429 and 503 are retried; the wait comes from the error body or the Retry-After header.
global calls
for attempt in range(tries):
r = requests.get(BASE, headers=HEADERS, params=params, timeout=90)
calls += 1
if r.status_code == 200:
return r.json().get("data") or {}
if r.status_code == 400:
print("bad request:", r.json()["error"]["message"].splitlines()[0])
return BAD
if r.status_code in (401, 402, 403):
sys.exit(f"HTTP {r.status_code}: check the API key and your credit balance")
if r.status_code not in (429, 503):
return GAVE_UP if r.status_code >= 500 else r.raise_for_status()
if attempt == tries - 1:
return GAVE_UP
try:
wait = r.json()["error"]["details"]["retryAfter"]
except (ValueError, KeyError, TypeError):
wait = None
try:
wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
except ValueError:
wait = 5 * (attempt + 1)
time.sleep(min(wait, 60))
return GAVE_UP
def windows(since, until, size):
# Inclusive day ranges. until: in a query includes the whole end day, so windows do not overlap.
if size == "none" or not since:
return [(since, until)]
start, end, out = date.fromisoformat(since), date.fromisoformat(until or str(date.today())), []
while start <= end:
if size == "day":
stop = start
elif size == "week":
stop = start + timedelta(days=6)
else:
nxt = (start.replace(day=1) + timedelta(days=32)).replace(day=1)
stop = nxt - timedelta(days=1)
stop = min(stop, end)
out.append((str(start), str(stop)))
start = stop + timedelta(days=1)
return out
def run(query, since, until, size, pages, sort, out):
seen, written = set(), 0
with open(out, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for lo, hi in windows(since, until, size):
q = f"({query})" + (f" since:{lo}" if lo else "") + (f" until:{hi}" if hi else "")
params, n, page, how = {"query": q, "sort": sort}, 0, 0, "ended"
while True:
if page == pages:
how = "stopped at the page cap"
break
data = get(params)
if data is BAD:
how = "skipped (bad request)"
break
if data is GAVE_UP:
how = "gave up (server errors or rate limit)"
break
page += 1
for p in data.get("posts") or []:
if p["id"] in seen: # one row per post id
continue
seen.add(p["id"])
n += 1
writer.writerow({
"id": p["id"], "created_at": p["created_at"], "url": p["url"],
"likes": p["like_count"], "reposts": p["repost_count"],
"replies": p["reply_count"], "quotes": p["quote_count"],
"views": p["view_count"], "lang": p["lang"],
"text": cell((p["text"] or "").replace("\n", " ")),
})
f.flush()
if not data.get("posts") or not data.get("has_more") or not data.get("cursor"):
break
params["cursor"] = data["cursor"]
time.sleep(0.2)
written += n
print(f"{lo or 'any'}..{hi or 'now'}: {n} new posts, {page} pages, {how}")
print(f"wrote {written} posts to {out} in {calls} calls")
def main():
ap = argparse.ArgumentParser(description="Search X posts through ToolzerHub and write a CSV.")
ap.add_argument("query", help='for example "from:NASA" or "artemis lang:en"')
ap.add_argument("--since", help="first day, YYYY-MM-DD")
ap.add_argument("--until", help="last day, YYYY-MM-DD (default: today when slicing)")
ap.add_argument("--window", choices=["none", "day", "week", "month"], default="none",
help="slice since..until into windows; each window pages to its own end")
ap.add_argument("--pages", type=int, default=60, help="page cap per window (default 60)")
ap.add_argument("--sort", choices=["latest", "top"], default="latest")
ap.add_argument("--out", default="x_search.csv")
a = ap.parse_args()
if not HEADERS["x-api-key"]:
sys.exit("set TOOLZERHUB_API_KEY first")
if a.window != "none" and not a.since:
sys.exit("--window needs --since")
run(a.query, a.since, a.until, a.window, a.pages, a.sort, a.out)
if __name__ == "__main__":
main()
A 400 skips that window, a 5xx gives up on it and moves on, and 429 and 503 are retried using the wait from the error body or the Retry-After header. We tested those branches against a local stand-in server, not live. cell() puts an apostrophe before text that starts with =, +, - or @, so a spreadsheet shows it as text instead of running it as a formula.
Run it
export TOOLZERHUB_API_KEY=your_key
python x_search.py "from:NASA" --since 2020-01-01 --until 2020-03-31 --window month --out nasa_2020.csv
Output on 2026-10-17:
2020-01-01..2020-01-31: 272 new posts, 15 pages, ended
2020-02-01..2020-02-29: 248 new posts, 14 pages, ended
2020-03-01..2020-03-31: 240 new posts, 13 pages, ended
wrote 760 posts to nasa_2020.csv in 42 calls
It took 65 seconds and gave 760 unique ids. The views column is 0 on all of them, so do not read it as a count for 2020 posts.
What 1,000 matching posts cost
We measured 18.1 posts a call in the 2020 run (760 in 42 calls) and 14.6 in the 14-window run (1,097 in 75), since each window ends on a part-filled page. That puts 1,000 posts at 56 to 69 calls, or 280 to 345 credits. (Our free-tier post's 250 credits per 1,000 is for profile posts, which come 20 a page.) Credits cost $0.99 to $1.90 per 1,000, from the $495 plan to the smallest pack. The free tier is 100 credits a month, 20 calls, about 290 to 360 posts.
| Job | X, per post read | ToolzerHub |
|---|---|---|
| 1,000 matching posts | $5.00 | $0.28 to $0.66 (280 to 345 credits) |
| The 760-post run above | $3.80 | $0.21 to $0.40 (210 credits) |
X's column assumes search is billed as a Post read and every post is a first read that day. We did not run a search against X, so we have not checked a real X bill.
Where X's API is the better pick
Pick X's API when you need volume per request, counts or anything beyond reading.
- Bigger pages. X returns up to 100 posts per request on recent search and 500 on full-archive; ours returns about 18.
- Counts. X has a counts endpoint at $0.005 per request; we found none.
- Streaming, filtered rules and anything that writes.
- Compliance, enterprise access and a contract with X.
For X's other prices and free credits, see is the Twitter API free. For another list that a single X call cannot cover, see the Twitter API followers list.
We are not affiliated with X Corp.
Sources
- X, Search Posts, https://docs.x.com/x-api/posts/search/introduction, read 2026-10-17
- X, Build a query, https://docs.x.com/x-api/posts/search/integrate/build-a-query, read 2026-10-17
- X, Search operators, https://docs.x.com/x-api/posts/search/integrate/operators, read 2026-10-17
- X, Full-archive search quickstart, https://docs.x.com/x-api/posts/search/quickstart/full-archive-search, read 2026-10-17
- X, Search all Posts reference, https://docs.x.com/x-api/posts/search-all-posts, read 2026-10-17
- X, Pay-per-usage pricing and credits, https://docs.x.com/x-api/getting-started/pricing, read 2026-10-17
- X, Rate limits, https://docs.x.com/x-api/fundamentals/rate-limits, read 2026-10-17
- X, API changelog (2026-05-04 entry), https://docs.x.com/changelog, read 2026-10-17
- ToolzerHub OpenAPI file, https://api.toolzerhub.com/openapi.json, checked 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17