A tweet search web app is one page where someone can search public X posts, set filters, and download the same rows as CSV. The page does not need an X account or an X developer app. It calls one Desearch endpoint, GET /twitter, from a short Python server in the standard library.
The worked example is Search public X posts with filters. The source is the tweet-search-web-app folder in the Desearch Python examples repo.
The basics
- Base URL:
https://api.desearch.ai - Auth:
Authorization: $DESEARCH_API_KEY(the raw key from Desearch Console) - Endpoint:
GET /twitter - SDK:
desearch-py1.2.1, methodx_search, if you want a client instead of plain HTTP
The example app itself does not import the SDK. requirements.txt lists pytest only. The snippets below are either taken from that folder or are a desearch-py 1.2.1 call.
Get a key
Create a key in Desearch Console and export it:
export DESEARCH_API_KEY=your_key
The example reads DESEARCH_API_KEY. It can also read a file path from DESEARCH_API_KEY_FILE. The key is sent on the request and is not written into the HTML.
Make one call
This asks for 20 latest posts in Spanish:
curl --get 'https://api.desearch.ai/twitter' \
--header "Authorization: $DESEARCH_API_KEY" \
--data-urlencode 'query=bitcoin' \
--data-urlencode 'sort=Latest' \
--data-urlencode 'count=20' \
--data-urlencode 'lang=es'
The same call in desearch-py 1.2.1:
import asyncio
import os
from desearch_py import Desearch
async def main() -> None:
async with Desearch(api_key=os.environ["DESEARCH_API_KEY"]) as client:
posts = await client.x_search(
query="bitcoin",
sort="Latest",
count=20,
lang="es",
)
print(posts)
asyncio.run(main())
x_search accepts query, sort, user, start_date, end_date, lang, verified, blue_verified, is_quote, is_video, is_image, min_retweets, min_replies, min_likes, and count. Those names match GET /twitter.
The example server builds the URL with the standard library:
url = BASE + "/twitter?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"Authorization": self.key, "User-Agent": UA})
BASE is https://api.desearch.ai. The response is a JSON list of posts. The page turns each post into a row.
Map the form to parameters
Each filter on the form is one query parameter:
| Form field | Parameter | What it sends |
|---|---|---|
| Search box | query | The search text. Required. |
| Sort | sort | Latest or Top. An empty form sends Latest. |
| Count | count | How many posts to ask for, from 1 to 100. An empty form sends 20. |
| Lang | lang | A 2 or 3 letter code, such as es. |
| Min likes | min_likes | A whole number of likes. |
| Min replies | min_replies | A whole number of replies. |
| Min reposts | min_retweets | A whole number of reposts. |
| Verified | verified | Sent as true when the box is checked. |
| Blue verified | blue_verified | Blue-check accounts. Sent as true when the box is checked. |
| Video | is_video | Sent as true when the box is checked. |
| Image | is_image | Sent as true when the box is checked. |
| Quote | is_quote | Sent as true when the box is checked. |
| From, To | start_date, end_date | UTC days, YYYY-MM-DD. |
Use blue_verified when you want blue-check accounts. Set start_date and end_date when the search should stay inside a date window. Dates are whole UTC days.
An optional "any of" list becomes an OR group. Several terms always keep the parentheses:
def build_query(terms):
"""Several terms always become (a OR b). A single term is passed as-is."""
terms = [t.strip() for t in terms if t and t.strip()]
if not terms:
raise FormError("Enter a search term.")
return terms[0] if len(terms) == 1 else "(" + " OR ".join(terms) + ")"
A main term plus that group is sent as one query. On 2026-10-16 the sample search was desearch with any of bittensor, subnet, sort Latest, count 15. The app sent desearch (bittensor OR subnet).
Hashtag and keyword operators you might put in the search box are covered in how to search hashtags on X and how to find old tweets by keyword. The parameter list for this endpoint is in the X Search API reference.
Check the form before you call
Check the form before the request is sent. The example rejects:
- a blank query
- a count outside 1 to 100
- a sort value other than
LatestorTop - a language code that is not 2 or 3 letters
- a minimum that is not a whole number of 0 or more
- a date that is not
YYYY-MM-DD - a start date after today in UTC
- an end date before the start date
A failed check returns HTTP 400 from the app and does not call the API.
Show results and a CSV
Each row has time in UTC, text, likes, replies, and a link in https://x.com/i/status/<id> form. Author names and handles are not shown. Mentions in the text are rewritten to @user.
def row(p):
"""One public result row. No author name or handle: the link uses x.com/i/status/<id>."""
t = parse_time(p.get("created_at"))
u = p.get("user") or {}
return {"id": str(p.get("id", "")),
"created_utc": t.strftime("%Y-%m-%d %H:%M") if t else "",
"text": HANDLE_RE.sub("@user", " ".join(str(p.get("text") or "").split())),
"lang": p.get("lang") or "",
"likes": p.get("like_count") or 0, "replies": p.get("reply_count") or 0,
"reposts": p.get("retweet_count") or 0,
"verified": bool(u.get("verified")), "blue": bool(u.get("is_blue_verified")),
"video": has_media(p, "video"), "image": has_media(p, "photo"),
"link": f"https://x.com/i/status/{p.get('id')}"}
The 15-post sample on 2026-10-16 returned 15 of 15 posts in 5.55 seconds. The first rows looked like this:
| created_utc | text (trimmed) | likes | replies | link |
|---|---|---|---|---|
| 2026-10-15 16:25 | Sunrise Digest · 2026-10-15 REAX Surges 558% as Smart Money Flows into Engy & Green Compute ... | 3 | 0 | https://x.com/i/status/2110769039865712878 |
| 2026-10-15 16:07 | Builder tip: one Desearch web search call can ask for page text and a publish-date window together ... | 4 | 0 | https://x.com/i/status/2110764481857114194 |
| 2026-10-15 15:54 | Which subnets win when gamma tokens go live? We mapped the subnets other subnets will actually pay to use ... | 36 | 1 | https://x.com/i/status/2110761337621995657 |
/search.csv downloads the same search as CSV.
The same base query, bitcoin, was checked with a count of 20 on 2026-10-16. These filters returned no rows outside the filter:
| Filter | Returned | Rows outside the filter |
|---|---|---|
lang=es | 20 | 0 |
min_likes=100 (Top) | 19 | 0 |
min_replies=20 (Top) | 20 | 0 |
min_retweets=50 (Top) | 20 | 0 |
blue_verified | 20 | 0 |
is_video | 20 | 0 |
is_quote | 20 | 0 |
| 2026-10-10 to 2026-10-12 | 20 | 0 (all on 2026-10-12) |
What it costs
X Search is listed at $0.15 per 1,000 posts on the pricing page (reference rate as of 7 May 2026). Console shows the rate on your account.
On 2026-10-16, 20-post searches took 1.47 to 6.4 seconds and the header cost was $0.003. The 15-post sample above cost $0.00225. Both amounts were on X-Desearch-Cost-Usd.
Run the example
The example folder includes app.py, core.py, offline tests, and a README.
python3 -m pip install -r requirements.txt
export DESEARCH_API_KEY=your_key
python3 app.py --port 8000
Open http://127.0.0.1:8000. The offline tests do not call the API. On 2026-10-16, python3 -m pytest -q reported 16 passed.
Frequently asked questions
How do I search for any of several words?
Put the extra words in the any-of field, separated by commas. The example turns two or more terms into (a OR b) and keeps the parentheses. One term is sent as typed.
How do I ask for blue-check accounts?
Check blue verified, or pass blue_verified=true. On 2026-10-16 that filter returned 20 posts for bitcoin, and none of them fell outside the filter.
How do I limit posts to a date range?
Set start_date and end_date as YYYY-MM-DD in UTC. A check from 2026-10-10 to 2026-10-12 returned 20 posts, all dated 2026-10-12.
Where is the full example?
The page, the CSV route, and the offline tests are in tweet-search-web-app. A walkthrough of the same app is on Search public X posts with filters.
