Grok X Search API: Capture X (Twitter) Post Data as Structured JSON
Advanced Data Extraction Specialist
TL;DR:
- Grok answers cite X posts, and the Scrapeless
scraper.grokactor returns those posts as a separate structured array.x_search_resultssits besideweb_search_resultsin the same payload, so one request yields both the open-web citations and the X (Twitter) citations without any HTML parsing. - Each X citation carries eleven keys, seven of which arrived populated in every capture.
post_id,user_name,name,text,create_time,view_count, andprofile_image_urlwere non-empty across all 167 post entries collected for this guide;citation_id,community_note,parent, andquotewere empty in all of them. - The prompt is the control surface, not a search parameter. A plain definitional question returned zero tool calls and two empty panels. Prompts that point Grok at X returned between 3 and 35 posts.
tool_usagesexposes the literal X query Grok ran. The array records the tool name and its arguments, so you can read back the exact search string — includingfrom:,since:,until:, andmin_faves:operators — that produced the posts you received.- The panel is not guaranteed to be deduplicated. Overlapping tool calls can repeat posts, and how often varies per capture: across seven captures the duplicate share ranged from 0% (18 entries, 18 distinct
post_idvalues) to 48% (25 entries, 13 distinct). Deduplicate before you count anything: two of the seven captures arrived with no repeats, and nothing in the response tells you which kind you received. - Reasoning mode did not control X sourcing depth. Two
MODEL_MODE_FAST/MODEL_MODE_EXPERTpairs on an identical prompt inverted, so treat mode as a reasoning setting rather than a volume dial. - Free to start. New Scrapeless accounts include free trial credits — sign up at app.scrapeless.com.
Ask Grok what people are posting about a telescope launch, and the answer arrives with a list of X posts underneath it. Those posts are the evidence the model selected, and the Scrapeless scraper.grok actor hands them back as JSON rows with author handles, timestamps, view counts, and post IDs already separated into fields.
That makes Grok a different route to X data than the usual one. The common approach pulls posts from the platform directly and aims at completeness. This guide covers the opposite direction: capturing the posts an answer engine chose to cite, which is a smaller and already-filtered slice, plus the query the model used to find them.
This guide covers the request shape, the exact field schema of an X citation, how to make the panel populate instead of arriving empty, and the tool_usages array that shows what Grok actually searched. For the general actor contract — envelope, modes, companion actors — see the Grok scraper API guide.
What the Grok X Search API Gives You
One request returns Grok's answer plus its two citation panels as discrete arrays. The X panel is the part this guide is about.
- Post-level rows, not a rendered feed. Each entry is an object with a stable
post_id, the author's handle and display name, the post text, an RFC 3339 timestamp, and a view count as an integer. - The model's own query, recorded.
tool_usagespreserves the search Grok issued, so a capture is reproducible and auditable rather than a black box. - Both panels from one call. A prompt that spans social reaction and official documentation returns X posts and open-web pages in the same payload, already separated.
- Time-bounded slices. Because Grok composes date operators into its X searches, prompts that name a window produce posts inside that window.
- Account-scoped captures. A prompt naming an account routes through an X user lookup and returns that account's recent posts.
The panel is a citation set rather than a complete archive. It reflects what one answer drew on, which suits citation tracking and sentiment sampling better than exhaustive collection.
Endpoint, Actor, and Parameters
- Synchronous endpoint:
POST https://api.scrapeless.com/api/v2/scraper/execute— blocks and returns the finished result. - Asynchronous endpoint:
POST https://api.scrapeless.com/api/v2/scraper/requestreturns atask_id;GET https://api.scrapeless.com/api/v2/scraper/result/{task_id}returns the result once it is ready. - Actor:
scraper.grok - Auth header:
x-api-token: $SCRAPELESS_API_KEY
| input field | required | description |
|---|---|---|
prompt |
yes | the question sent to Grok; this is what determines whether the X panel populates |
country |
yes | two-letter country code for the run's residential egress, for example US |
mode |
yes | reasoning depth — MODEL_MODE_FAST or MODEL_MODE_EXPERT |
Captures for this guide finished in roughly 16 to 60 seconds. The synchronous endpoint suits a quick check from the command line. For anything scripted, prefer the asynchronous pair: it answers the submit call with HTTP 201 and a task_id, then returns the 202 Accepted status defined in the HTTP semantics specification while the task is still running and 200 with status: "success" once the result is ready. Polling for that transition is what makes a client deterministic regardless of how long a given prompt takes.
Keep the key in the environment rather than in code:
bash
export SCRAPELESS_API_KEY="your_api_token_here"
Your First Capture
This request names an account, which is the most reliable way to get a populated X panel on the first try. The jq filter prints the two panel sizes and the tools Grok invoked.
bash
curl -sS -X POST https://api.scrapeless.com/api/v2/scraper/execute \
-H "Content-Type: application/json" \
-H "x-api-token: ${SCRAPELESS_API_KEY}" \
-d '{
"actor": "scraper.grok",
"input": {
"prompt": "What has @NASA posted on X recently?",
"country": "US",
"mode": "MODEL_MODE_FAST"
}
}' | jq '{
x_posts: (.task_result.x_search_results | length),
web_pages: (.task_result.web_search_results | length),
tools: [.task_result.tool_usages[].tool_name]
}'
One capture of that request returned {"x_posts": 20, "web_pages": 0, "tools": ["x_keyword_search", "x_keyword_search"]} — twenty X posts, no open-web pages, and two keyword searches. Counts move between runs, so treat the shape as the contract and the numbers as a sample. If x_posts is 0 and tools is empty, Grok answered from its own knowledge and searched nothing — see making the X panel populate below.
The X Post Schema, Field by Field
Every entry in x_search_results is a flat object with the same eleven keys. This is one real capture from the @NASA request above:
json
// captured from a live scraper.grok run; a single x_search_results entry
{
"citation_id": "",
"community_note": "",
"create_time": "2026-08-06T11:00:59Z",
"name": "NASA",
"parent": null,
"post_id": "2085320225776427457",
"profile_image_url": "https://pbs.twimg.com/profile_images/1321163587679784960/0ZxKlEKB_normal.jpg",
"quote": null,
"text": "LIVE: Time for a spacewalk! Watch as @Astro_Jessica and @Astro_Anil step outside the @Space_Station to prepare the orbiting lab for a new solar array.",
"user_name": "NASA",
"view_count": 714862
}
Across 167 post entries captured for this guide, the fields split cleanly into two groups:
| field | type | populated | what it holds |
|---|---|---|---|
post_id |
string | always | the numeric post identifier, as a string; the natural primary key |
user_name |
string | always | the author's handle — the @ name, without the @ |
name |
string | always | the author's display name, which often differs from the handle |
text |
string | always | the post body, including newlines, mentions, and t.co shortlinks |
create_time |
string | always | post timestamp in the RFC 3339 date and time format, UTC, Z-suffixed |
view_count |
integer | always | view count as a number; observed range across captures was 0 to 6,937,545 |
profile_image_url |
string | always | the author's avatar, on the platform's image CDN |
citation_id |
string | never | empty string in all 167 entries |
community_note |
string | never | empty string in all 167 entries |
parent |
null | never | null in all 167 entries |
quote |
null | never | null in all 167 entries |
The four never-populated fields deserve a caveat. They are present in the schema on every entry, so code that reads them will not raise, but nothing in this capture set filled them. Build on the seven that arrive populated, and treat the other four as reserved rather than as a reply-threading or Community Notes feature you can depend on.
user_name and post_id together reconstruct a canonical post URL as https://x.com/<user_name>/status/<post_id>, which is useful for storing a link back to the source.
Get your API key on the free plan: app.scrapeless.com
Reading the Query Grok Actually Ran
tool_usages is the field that separates this capture route from a plain X search. Each entry names a tool and carries its arguments as a JSON string, so you can read back exactly what was searched.
python
import json
import os
import time
import requests
BASE = "https://api.scrapeless.com/api/v2/scraper"
HEADERS = {
"Content-Type": "application/json",
"x-api-token": os.environ["SCRAPELESS_API_KEY"],
}
def capture(prompt, country="US", mode="MODEL_MODE_FAST"):
"""Submit a Grok capture, then poll until the task_result is ready."""
submit = requests.post(
f"{BASE}/request",
headers=HEADERS,
json={
"actor": "scraper.grok",
"input": {"prompt": prompt, "country": country, "mode": mode},
},
timeout=60,
)
submit.raise_for_status()
task_id = submit.json()["task_id"]
for _ in range(120):
poll = requests.get(f"{BASE}/result/{task_id}", headers=HEADERS, timeout=60)
poll.raise_for_status()
body = poll.json()
if body.get("status") == "success":
return body["task_result"]
time.sleep(5)
raise TimeoutError(f"task {task_id} did not finish in the allotted window")
result = capture("Search X for posts from:NASA about Artemis since:2026-07-01 and summarize them.")
for call in result.get("tool_usages") or []:
print(call["tool_name"])
for key, value in json.loads(call["tool_args"]).items():
print(f" {key}: {value}")
print(f"x_search_results: {len(result.get('x_search_results') or [])}")
That prompt embeds two X search operators, and they survive into the tool call verbatim:
text
x_keyword_search
query: from:NASA Artemis since:2026-07-01
limit: 10
mode: Latest
x_search_results: 3
The operators you write in the prompt become the operators in the query. Across captures, Grok composed from:, since:, until:, lang:, and min_faves: into its searches, together with a mode of Top or Latest. Some of those match the platform's published search operator reference, which lists from: and lang: alongside engagement filters; the date-bounding since: and until: forms come from the search interface rather than that reference. Three distinct X tools appeared:
| tool | arguments observed | what it does |
|---|---|---|
x_keyword_search |
query, limit, mode |
operator-driven keyword search; mode selects Top or Latest |
x_semantic_search |
query, limit, from_date, to_date, min_score_threshold |
meaning-based search over a date window |
x_user_search |
query, count |
account lookup, used when a prompt names a handle |
Two non-X tools share the array: web_search with query and num_results, and open_page with url and start_line, which fills web_search_results instead.
Logging tool_usages alongside every capture turns a stored result into something you can explain later — the posts you kept, and the query that found them.
Making the X Panel Populate
An empty x_search_results is not an error condition. It means Grok answered without searching X. The distinction is visible in the same payload: when the panel is empty because nothing was searched, tool_usages is empty too.
A plain definitional prompt — "What is a headless browser?" — returned zero tool calls, zero X posts, and zero web pages. Every prompt that pointed at X returned posts. Measured across the captures for this guide:
| prompt shape | X posts | web pages | tools invoked |
|---|---|---|---|
| plain definitional question | 0 | 0 | none |
| "what are people saying on X about …" | 15 | 0 | keyword × 2, semantic |
| "what has @account posted on X recently" | 10 | 0 | keyword, user |
| "what is trending on X in …" | 10 | 10 | web, semantic, keyword |
| explicit operators, "search X for from:… since:…" | 3 | 0 | keyword |
| social reaction plus official source | 35 | 16 | web × 2, semantic × 3, keyword × 4, open_page |
Three prompt patterns populated the panel dependably:
- Name the platform. "on X" in the prompt is the strongest single signal.
- Name an account. A handle routes through
x_user_searchand returns that account's posts. - Ask for reaction, sentiment, or discussion. These pull posts where a factual question would resolve from the open web.
The last row does something the others do not. A prompt that asks for both social reaction and the official source populates both panels, so one call returns what people are posting alongside what the primary source says.
Structured-Output Handling in Python
The panel needs one transform before it is usable: deduplication. Overlapping tool calls can return the same post more than once, so array length is an upper bound on the number of distinct posts rather than a count of them. Some captures come back with no repeats at all; others repeat nearly half their entries. Since you cannot tell which you got without checking, deduplicate unconditionally.
python
import json
import os
import time
import requests
BASE = "https://api.scrapeless.com/api/v2/scraper"
HEADERS = {
"Content-Type": "application/json",
"x-api-token": os.environ["SCRAPELESS_API_KEY"],
}
def capture(prompt, country="US", mode="MODEL_MODE_FAST"):
"""Submit a Grok capture, then poll until the task_result is ready."""
submit = requests.post(
f"{BASE}/request",
headers=HEADERS,
json={
"actor": "scraper.grok",
"input": {"prompt": prompt, "country": country, "mode": mode},
},
timeout=60,
)
submit.raise_for_status()
task_id = submit.json()["task_id"]
print(f"submitted task_id={task_id}")
for _ in range(120):
poll = requests.get(f"{BASE}/result/{task_id}", headers=HEADERS, timeout=60)
poll.raise_for_status()
body = poll.json()
if body.get("status") == "success":
return body["task_result"]
time.sleep(5)
raise TimeoutError(f"task {task_id} did not finish in the allotted window")
def x_rows(task_result):
"""Flatten x_search_results into unique rows keyed by post_id."""
seen, rows = set(), []
for post in task_result.get("x_search_results") or []:
post_id = post.get("post_id")
if not post_id or post_id in seen:
continue
seen.add(post_id)
handle = post.get("user_name") or ""
rows.append(
{
"post_id": post_id,
"handle": handle,
"display_name": post.get("name") or "",
"posted_at": post.get("create_time") or "",
"views": post.get("view_count") or 0,
"text": " ".join((post.get("text") or "").split()),
"url": f"https://x.com/{handle}/status/{post_id}",
}
)
return rows
result = capture("What are people saying on X about the James Webb Space Telescope this week?")
rows = x_rows(result)
raw_count = len(result.get("x_search_results") or [])
print(f"raw={raw_count} unique={len(rows)}")
for row in sorted(rows, key=lambda r: r["views"], reverse=True)[:3]:
print(f"@{row['handle']} · {row['posted_at']} · {row['views']:,} views")
print(f" {row['text'][:100]}")
print(f" {row['url']}")
print(json.dumps(rows[:1], ensure_ascii=False, indent=2))
x_rows returns exactly the shape a table or warehouse column set wants: one row per distinct post, a resolvable URL, and an integer view count you can sort on. The list is plain JSON-serializable dictionaries, so it drops straight into a DataFrame or an insert statement.
Sorting by views before you sample is usually the right move, because the panel mixes very large accounts with very small ones — the observed range in one capture set ran from 0 to nearly 7 million views on the same prompt.
Common Data-Shape Problems
- Array length is not the post count. Deduplicate on
post_idbefore counting or charting. Measured across seven captures: 15 entries / 13 unique, 35 / 29, 34 / 31, 25 / 13, 15 / 14, and two captures that repeated nothing (10 / 10 and 18 / 18). The duplicate share is not stable enough to predict — build the dedupe step in regardless, because a share-of-voice number built on raw length overstates every account that two tool calls both surfaced. - Four fields are structurally present but were always empty.
citation_id,community_note,parent, andquoteappeared on every entry and were empty in all 167. Do not design a reply-thread or Community Notes feature around them without confirming they populate for your prompts. view_countis an integer,post_idis a string. Post identifiers observed here run past 2×10^18, beyond the range a double-precision float represents exactly — which is why the JSON specification's guidance on number interoperability warns against relying on numeric precision across implementations. Keeppost_idas text end to end; casting it to a float is how post IDs silently change value.- Mode is not a volume dial. Two
FAST/EXPERTpairs on one identical prompt returned 15 vs 20 posts, then 25 vs 15 — the ordering inverted. Panel size varied more between two runs of the same setting than between settings. Hold mode constant across a tracked series for methodological consistency, not because it guarantees deeper sourcing. - The same prompt returns a different panel each run. Grok recomposes its query per run, so wording drifts and so does the result set. Read a series rather than a single capture, and store
tool_usagesso you can see which runs asked different questions. - Panels populate independently. A prompt can fill the X panel and leave
web_search_resultsempty, or fill both. Check each array's length separately rather than assuming one implies the other.
Conclusion
The X panel in a Grok capture is a small, well-typed dataset sitting inside an answer payload. One POST to scraper.grok with a prompt that names X returns post IDs, handles, display names, post text, UTC timestamps, and view counts as flat JSON — seven fields that arrived populated in every entry captured for this guide. Deduplicate on post_id, keep the identifier as a string, and log tool_usages so each stored row carries the query that found it. The result covers a narrow slice of the platform rather than an archive of it: the posts an answer engine judged worth citing, with the search that surfaced them attached.
Start Capturing X Citations From Grok Answers
Join our community to claim a free plan and compare notes with developers building answer-engine pipelines: Discord · Telegram.
Sign up at app.scrapeless.com for free trial credits, then point scraper.grok at the accounts, topics, and windows your monitoring program tracks. The Universal Scraping API page covers the wider actor family, and current usage tiers are on the pricing page.
FAQ
Q: Why is x_search_results empty on my request?
Because Grok answered without searching X. Check tool_usages in the same payload: if it is also empty, no search ran at all. Prompts that name the platform ("on X"), name an account, or ask about reaction and discussion populated the panel in every capture for this guide, while a plain definitional question returned zero tools and zero posts.
Q: What fields does each X post actually contain?
Eleven keys, present on every entry. Seven were populated in all 167 entries captured here: post_id, user_name, name, text, create_time, view_count, and profile_image_url. The remaining four — citation_id, community_note, parent, and quote — were empty in every one.
Q: Can I control which X posts Grok searches for?
Yes, through the prompt. Search operators written into the prompt propagate into the query Grok issues: a prompt containing from:NASA and since:2026-07-01 produced the tool call query: from:NASA Artemis since:2026-07-01. Read tool_usages after each run to confirm what was searched.
Q: How do I rebuild a link to the original post?
Combine two always-populated fields: https://x.com/<user_name>/status/<post_id>. Keep post_id as a string — it is long enough to lose precision if a JSON parser reads it as a floating-point number.
Q: Does MODEL_MODE_EXPERT return more X posts than MODEL_MODE_FAST?
Not dependably. Two paired runs on an identical prompt returned 15 posts under FAST and 20 under EXPERT, then 25 under FAST and 15 under EXPERT. Run-to-run variation was larger than the difference between modes. Pick one mode and hold it constant so a tracked series stays comparable.
Q: How is this different from collecting posts from the platform directly?
Scope and selection. This route returns the posts one answer cited — an editorially filtered sample, typically 3 to 35 posts, with the model's query attached. Direct collection targets completeness instead. Use this when the question is what an answer engine surfaced and credited; use a dedicated collection route when you need exhaustive coverage of a hashtag or account.
Q: What should I keep in mind about the post data itself?
Post text and author names are public content authored by real people, so treat a stored capture as a dataset about individuals. Keep collection bounded and purposeful, retain only the fields your analysis needs, and check whether the EU General Data Protection Regulation or a comparable regime applies to your use, especially before republishing post text or profile images. Platform terms govern reuse independently of data-protection law; review both and consult counsel for your specific case.
Q: Do I need a proxy?
No. Country-pinned residential egress is built into the actor, and the required country input is the entire configuration.
At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.



