XAnswer

Twitter API search tweets: how far back, how deep and what 1,000 posts cost

X's recent search covers 7 days; X bills posts at $0.005. Ours ends a query after about 900 to 2,100 posts, days on busy words; slice by date.

10 min read

Short answer

  • X's API searches posts two ways: recent search covers the last 7 days, full-archive search goes back to 2006. X's pricing page prints no search price, so we count each result as a Post read at $0.005 (docs.x.com, read 2026-10-17).
  • The catch on our side: one /v1/x/search query stops on its own, after 906 to 2,042 posts in the four unsliced queries that ended (it is not a fixed number), and a busy keyword may cover only days. Add since: and until: to slice by date. Each call is 5 credits and returns about 18 posts.
  • 1,000 matching posts would take 56 to 69 calls at the rates we measured, 280 to 345 credits: $0.28 to $0.66, against $5.00 on X. 100 free credits a month. The script below pages date windows to their end and writes a CSV.
On this page

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_time and end_time.

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.

python
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

bash
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:

text
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

Get this data from your own code

The X (Twitter) API returns it as JSON with one x-api-key header. 100 free credits monthly.