Skip to main content
Use the CLI when a shell script, cron job, CI workflow, or operations runbook needs X API data without an application wrapper. It can search tweets, scrape tweets to JSON Lines, CSV, or XLSX, export follower data, upload media, post media tweets, post tweet replies, send direct messages, monitor tweets, and hand results to jq, Python, a warehouse loader, or a queue worker.

Install

go install github.com/Xquik-dev/x-twitter-scraper-cli/cmd/x-twitter-scraper@latest
Make sure your Go bin directory is on PATH:
export PATH="$PATH:$(go env GOPATH)/bin"

Authenticate

export X_TWITTER_SCRAPER_API_KEY="xq_YOUR_KEY_HERE"
You can also pass --api-key per command or use X_TWITTER_SCRAPER_BEARER_TOKEN for OAuth 2.1 access tokens.

Basic Example

x-twitter-scraper x:tweets search \
  --q from:elonmusk \
  --limit 10 \
  --format json
Use --help at any level:
x-twitter-scraper x:tweets search --help

Workflow: Search Tweets to JSON Lines, CSV, or XLSX

Use this workflow when an analyst, data engineer, or growth operator needs tweets from an X search query in a JSON Lines handoff, analyst CSV file, XLSX workbook, warehouse load, CRM enrichment job, or queue. The CLI command below calls GET /x/tweets/search. It maps the same REST API query parameters to flags: --q, --limit, --cursor, --since-time, --until-time, and --query-type.
query="from:xquikcom webhook OR SDK"
cursor=""
page_index=0
headers='["source","query","tweet_id","text","author_id","author_username","author_name","created_at","like_count","reply_count","retweet_count","quote_count","view_count","bookmark_count","is_note_tweet","page_index","page_cursor","next_cursor","has_next_page"]'

: > xquik-tweet-search.jsonl
jq -nr "$headers | @csv" > xquik-tweet-search.csv

while :; do
  page_cursor="$cursor"

  if [ -n "$cursor" ]; then
    x-twitter-scraper x:tweets search \
      --q "$query" \
      --query-type Latest \
      --cursor "$cursor" \
      --limit 100 \
      --format json \
      --format-error json > search-page.json
  else
    x-twitter-scraper x:tweets search \
      --q "$query" \
      --query-type Latest \
      --limit 100 \
      --format json \
      --format-error json > search-page.json
  fi

  has_next_page="$(jq -r '.has_next_page // false' search-page.json)"
  next_cursor="$(jq -r 'if .has_next_page then (.next_cursor // "") else "" end' search-page.json)"

  jq -c \
    --arg source "xquik.cli.search" \
    --arg query "$query" \
    --arg page_cursor "$page_cursor" \
    --arg next_cursor "$next_cursor" \
    --argjson page_index "$page_index" \
    --argjson has_next_page "$has_next_page" \
    '.tweets[] | {
      source: $source,
      query: $query,
      tweet_id: .id,
      text: .text,
      author_id: (.author.id // ""),
      author_username: (.author.username // ""),
      author_name: (.author.name // ""),
      created_at: (.createdAt // ""),
      like_count: (.likeCount // 0),
      reply_count: (.replyCount // 0),
      retweet_count: (.retweetCount // 0),
      quote_count: (.quoteCount // 0),
      view_count: (.viewCount // 0),
      bookmark_count: (.bookmarkCount // 0),
      is_note_tweet: (.isNoteTweet // false),
      page_index: $page_index,
      page_cursor: $page_cursor,
      next_cursor: $next_cursor,
      has_next_page: $has_next_page
    }' search-page.json >> xquik-tweet-search.jsonl

  jq -r \
    --arg source "xquik.cli.search" \
    --arg query "$query" \
    --arg page_cursor "$page_cursor" \
    --arg next_cursor "$next_cursor" \
    --argjson page_index "$page_index" \
    --argjson has_next_page "$has_next_page" \
    '.tweets[] | [
      $source,
      $query,
      .id,
      .text,
      (.author.id // ""),
      (.author.username // ""),
      (.author.name // ""),
      (.createdAt // ""),
      (.likeCount // 0),
      (.replyCount // 0),
      (.retweetCount // 0),
      (.quoteCount // 0),
      (.viewCount // 0),
      (.bookmarkCount // 0),
      (.isNoteTweet // false),
      $page_index,
      $page_cursor,
      $next_cursor,
      $has_next_page
    ] | @csv' search-page.json >> xquik-tweet-search.csv

  [ "$has_next_page" = "true" ] || break
  [ -n "$next_cursor" ] || break
  cursor="$next_cursor"
  page_index=$((page_index + 1))
done
The response includes .tweets[], .has_next_page, and .next_cursor. Each tweet can include id, text, createdAt, engagement counts, bookmarkCount, isNoteTweet, author, media, quoted_tweet, and retweeted_tweet, depending on what X returns for the result. Project .tweets[] into xquik-tweet-search.jsonl and xquik-tweet-search.csv rows with tweet_id, author_username, engagement counts, page_index, page_cursor, next_cursor, and has_next_page so scripts can resume safely or load the same records into XLSX, CRM, warehouse, or agent workflows.
python3 - <<'PY'
import csv
import json

rows = [json.loads(line) for line in open("xquik-tweet-search.jsonl", encoding="utf-8")]
fieldnames = [
    "source",
    "query",
    "tweet_id",
    "text",
    "author_id",
    "author_username",
    "author_name",
    "created_at",
    "like_count",
    "reply_count",
    "retweet_count",
    "quote_count",
    "view_count",
    "bookmark_count",
    "is_note_tweet",
    "page_index",
    "page_cursor",
    "next_cursor",
    "has_next_page",
]
with open("xquik-tweet-search-from-jsonl.csv", "w", newline="", encoding="utf-8") as handle:
    writer = csv.DictWriter(handle, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)
PY
Save the cursor with the job checkpoint before requesting the next page:
tail -n 1 xquik-tweet-search.jsonl | jq '{query, page_index, next_cursor, has_next_page}'
Use --limit as a 1 to 200 upper bound for a bounded pull. When .has_next_page is true, keep the same --q, filters, --query-type, and --limit; only --cursor changes. Tweet search costs 1 credit per tweet returned. If remaining credits cannot cover a bounded --limit request, the API can return fewer tweets; if 0 paid results are affordable, it returns 402 insufficient_credits. For bounded pulls that return fewer tweets than the requested --limit, pass .next_cursor back as --cursor with the same query, filters, --query-type, and --limit. Use --format-error json so batch jobs can branch on status and code, and retry 429 or temporary 5xx responses with backoff. For XLSX handoff, keep xquik-tweet-search.jsonl as the source of truth and convert the same projected rows in your pipeline when account managers need a workbook.

Workflow: Follower Export to CSV, JSON, or XLSX

Use this workflow when sales, support, research, or marketing teams need an owned follower list in a CRM import, warehouse load, analyst CSV file, XLSX workbook, or resumable JSON handoff. follower_explorer requires targetUsername. The CLI flag is --target-username.
x-twitter-scraper extractions estimate-cost \
  --tool-type follower_explorer \
  --target-username xquikcom \
  --format json > follower-estimate.json
The estimate calls POST /extractions/estimate and returns allowed, estimatedResults, creditsRequired, creditsAvailable, and source. For follower exports, source is usually followers.
jq -e '.allowed == true' follower-estimate.json >/dev/null

x-twitter-scraper extractions run \
  --tool-type follower_explorer \
  --target-username xquikcom \
  --format json > follower-run.json

jq -e '.status == "running" and .toolType == "follower_explorer"' follower-run.json >/dev/null
job_id="$(jq -r '.id' follower-run.json)"
The run command calls POST /extractions and returns the queued 202 Accepted receipt: id, toolType, and status: "running". Persist follower-run.json, job_id, the source username, and the estimate before polling. Credit reservation happens after the job starts. If available credits changed since the estimate, the run can fetch only the affordable count before export or mark the job failed with insufficient_credits.
while :; do
  x-twitter-scraper extractions retrieve \
    --id "$job_id" \
    --limit 1000 \
    --format json > followers-page.json

  status="$(jq -r '.job.status' followers-page.json)"
  [ "$status" = "completed" ] && break
  [ "$status" = "failed" ] && exit 1
  sleep 10
done
The retrieve command returns .job, .results, .hasMore, and .nextCursor. When .hasMore is true, pass .nextCursor back as --after to fetch the next saved page.
: > xquik-followers.jsonl
after=""

while :; do
  if [ -n "$after" ]; then
    x-twitter-scraper extractions retrieve \
      --id "$job_id" \
      --after "$after" \
      --limit 1000 \
      --format json > followers-page.json
  else
    x-twitter-scraper extractions retrieve \
      --id "$job_id" \
      --limit 1000 \
      --format json > followers-page.json
  fi

  jq -c '.results[]' followers-page.json >> xquik-followers.jsonl

  has_more="$(jq -r '.hasMore // false' followers-page.json)"
  next_cursor="$(
    jq -r 'if .hasMore then (.nextCursor // "") else "" end' followers-page.json
  )"
  [ "$has_more" = "true" ] || break
  [ -n "$next_cursor" ] || break
  after="$next_cursor"
done
x-twitter-scraper extractions export-results \
  --id "$job_id" \
  --format csv \
  --output xquik-followers.csv
Use --format json --output xquik-followers.json for app ingestion or --format xlsx --output xquik-followers.xlsx for XLSX handoff. Map User ID or result xUserId as the CRM unique key. Keep xquik-followers.jsonl for queue replay or warehouse loads, xquik-followers.json for app ingestion, xquik-followers.csv for CRM import, and xquik-followers.xlsx for analyst handoff. Cost: 1 credit per follower extracted or returned. Exports are free after the extraction job exists.

Workflow: Tweet Replies to CSV, JSON, or XLSX

Use this workflow when moderation, support, giveaway, research, or AI review needs every reply under one tweet as CSV, JSON, XLSX, or JSONL. Estimate, run reply_extractor, poll the stored job, then export the file. reply_extractor requires targetTweetId. The CLI flag is --target-tweet-id.
x-twitter-scraper extractions estimate-cost \
  --tool-type reply_extractor \
  --target-tweet-id 1893704267862470862 \
  --format json > reply-estimate.json
The estimate calls POST /extractions/estimate and returns allowed, estimatedResults, creditsRequired, creditsAvailable, and source. For replies, source is usually replyCount.
jq -e '.allowed == true' reply-estimate.json >/dev/null

job_id="$(x-twitter-scraper extractions run \
  --tool-type reply_extractor \
  --target-tweet-id 1893704267862470862 \
  --transform id \
  --raw-output)"
The run command calls POST /extractions. Persist job_id before polling so a shell restart, queue retry, or CI rerun can resume the same extraction. Reuse the follower export polling loop above with reply filenames:
x-twitter-scraper extractions retrieve \
  --id "$job_id" \
  --limit 100 \
  --format json > replies-page.json

next_cursor="$(jq -r '.nextCursor // empty' replies-page.json)"

x-twitter-scraper extractions retrieve \
  --id "$job_id" \
  --after "$next_cursor" \
  --limit 100 \
  --format json > replies-next-page.json
The retrieve command calls GET /extractions/{id} and returns .job, .results, .hasMore, and .nextCursor. Append .results[] to your queue, CRM import, or warehouse load. While .hasMore is true, pass .nextCursor back as --after "$next_cursor".
x-twitter-scraper extractions export-results \
  --id "$job_id" \
  --format csv \
  --output xquik-tweet-replies.csv
The export command calls GET /extractions/{id}/export and writes bytes to --output. Use --format csv, --format json with --output xquik-tweet-replies.json, or --format xlsx with --output xquik-tweet-replies.xlsx. Cost: 1 credit per reply extracted or returned. Exports are free after the extraction job exists.

Workflow: Post Media Tweets, Replies, and DM Attachments

Use this workflow when an operator, support queue, or agent needs to publish a media-backed tweet, reply with media, or send one media attachment in a DM from a connected X account. Tweet and reply media posts use public media URLs directly on x:tweets create. Pass repeated --media flags for up to 4 image URLs, or pass exactly 1 MP4 video URL up to 100 MB. Do not mix video with other media. Do not upload first when you already have public media URLs.
x-twitter-scraper x:tweets create \
  --account @xquikcom \
  --text "New demo video is live." \
  --media https://example.com/product-demo.mp4 \
  --format json \
  --format-error json > posted-tweet.json
To reply with media, add the parent tweet ID. The --reply-to-tweet-id flag maps to reply_to_tweet_id, and --media maps to the public URL array on POST /x/tweets.
x-twitter-scraper x:tweets create \
  --account @xquikcom \
  --text "Here is the requested screenshot." \
  --reply-to-tweet-id 1893704267862470862 \
  --media https://example.com/export-preview.png \
  --format json \
  --format-error json > posted-reply.json
The response returns tweetId, success, charged, and chargedCredits. If the API returns 202 x_write_unconfirmed, store writeActionId, chargedCredits, and poll GET /x/write-actions/{id} before sending another write. Keep one JSON Lines handoff shape for both outcomes:
write_handoff() {
  input_file="$1"

  jq -c '
    if .error == "x_write_unconfirmed" then
      {
        status,
        write_action_id: .writeActionId,
        charged,
        charged_credits: .chargedCredits,
        retryable,
        poll: "GET /x/write-actions/{id}"
      }
    else
      {
        status: "posted",
        tweet_id: .tweetId,
        charged,
        charged_credits: .chargedCredits,
        write_action_id: .writeActionId
      }
    end
  ' "$input_file"
}

write_handoff posted-tweet.json > tweet-handoff.jsonl
write_handoff posted-reply.json > reply-handoff.jsonl
Each create-tweet call costs 10 credits. Use x:media upload when you need an uploaded media ID for a DM attachment. The CLI upload command accepts a local file through --file; --transform mediaId extracts the upload response ID for scripts.
media_id="$(x-twitter-scraper x:media upload \
  --account @xquikcom \
  --file ./handoff.png \
  --transform mediaId \
  --raw-output)"

x-twitter-scraper x:dm send \
  --account @xquikcom \
  --user-id 44196397 \
  --text "Here is the asset." \
  --media-id "$media_id" \
  --format json > sent-dm.json

jq -c --arg media_id "$media_id" '{
  status: "sent",
  message_id: .messageId,
  media_id: $media_id
}' sent-dm.json > dm-handoff.jsonl
The --media-id flag maps to the REST media_ids body field. DMs accept exactly 1 uploaded media ID. Store messageId from the DM response on the support ticket, CRM record, queue job, or agent memory. Keep DM body text in private systems. Shared logs, public artifacts, queue status, and agent handoffs should store message_id, optional media_id, recipient/account identifiers from your job context, and send status instead of full DM bodies. Uploading media costs 10 credits, and sending the DM costs 10 credits. Do not pass --reply-to-message-id to x:dm send; the REST endpoint rejects reply_to_message_id. Start a new DM with --user-id, --account, --text, and optional one-item --media-id instead. Do not pass uploaded mediaId values to x:tweets create; that command uses --media with public media URLs.

Useful Commands

Search tweets or scrape tweets

Run x-twitter-scraper x:tweets search to return tweet objects, author objects, metrics, media, has_next_page, and next_cursor.

Fetch one tweet

Run x-twitter-scraper x:tweets retrieve to return the full tweet text, author, metrics, media, quoted tweet, and retweeted tweet.

Get tweet replies

Run x-twitter-scraper x:tweets get-replies to return reply tweets plus cursor fields.

Export followers

Run x-twitter-scraper x:users retrieve-followers to return user profiles, follower counts, and cursor fields.

Post a tweet or reply

Run x-twitter-scraper x:tweets create to return tweetId, success, charged, and chargedCredits, or writeActionId when confirmation is pending.

Upload media for a DM attachment

Run x-twitter-scraper x:media upload to return mediaId for one-item DM media_ids and mediaUrl for tweet media URL handoff.

Send a direct message

Run x-twitter-scraper x:dm send to return messageId and success.
Use --format json for scripts, --format yaml for readable operations output, and --transform with GJSON syntax when you only need one field from the response.

Error Handling

The CLI writes API errors to stderr and exits non-zero for failed requests. Use --format-error json when scripts need machine-readable errors.
x-twitter-scraper x:users retrieve-search \
  --q not-a-real-user \
  --format json \
  --format-error json
Retryable API responses follow the same semantics as the REST API. See error handling and rate limits.

Pagination

Commands that call paginated endpoints include pagination fields in their JSON output. Keep the response body when your script needs to request the next page with endpoint cursor parameters.

Webhooks & References

Last modified on May 25, 2026