> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xquik.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Ruby SDK

> Use the Xquik Ruby SDK to search tweets, export CSV, JSON Lines, or XLSX, post tweets, upload media, send DMs, and run Rails or Sidekiq X API jobs.

<blockquote className="agent-llms-directive">
  For the complete documentation index, see <a href="/llms.txt">llms.txt</a>.
</blockquote>

Use the Ruby SDK for Ruby 3.2+ applications that need typed REST access, retries, Yard docs, RBS, RBI, and connection pooling.

Use this page when you need a Ruby app, Rails job, or Sidekiq worker to search tweets, scrape tweets to CSV, JSON Lines, or XLSX, export followers, upload media, send direct messages, monitor tweets, or hand X data to a warehouse, CRM, queue, or agent workflow.

## Install

```bash theme={null}
gem install x-twitter-scraper
```

Or add it to your Gemfile:

```ruby theme={null}
gem "x-twitter-scraper", "~> 0.5.3"
```

## Authenticate

```bash theme={null}
export X_TWITTER_SCRAPER_API_KEY="xq_YOUR_KEY_HERE"
```

## Basic Example

Search tweets and write durable JSON Lines handoff rows:

```ruby theme={null}
require "json"
require "x_twitter_scraper"

client = XTwitterScraper::Client.new(
  api_key: ENV["X_TWITTER_SCRAPER_API_KEY"]
)

page = client.x.tweets.search(
  q: "from:username webhook OR SDK",
  limit: 10
)

page.tweets.each do |tweet|
  row = {
    tweet_id: tweet.id,
    text: tweet.text,
    author_username: tweet.author&.username,
    created_at: tweet.created_at
  }

  puts(JSON.generate(row))
end
```

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

This job is for Ruby workers, Rails jobs, Sidekiq queues, and agent tools that need tweet search results in durable handoff files. It calls `GET /x/tweets/search` through `client.x.tweets.search`, uses the generated `XTwitterScraper::X::TweetSearchParams` shape, and writes analyst-friendly CSV plus JSON Lines for queues, warehouses, and replayable processing.

```ruby theme={null}
require "csv"
require "json"
require "x_twitter_scraper"

client = XTwitterScraper::Client.new(
  api_key: ENV["X_TWITTER_SCRAPER_API_KEY"]
)

query = "from:username webhook OR SDK"
cursor = nil
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"
]

CSV.open("xquik-tweet-search.csv", "w", write_headers: true, headers: headers) do |csv|
  File.open("xquik-tweet-search.jsonl", "w") do |jsonl|
    loop do
      page_cursor = cursor
      page = client.x.tweets.search(
        q: query,
        query_type: :Latest,
        cursor: cursor
      )

      page.tweets.each do |tweet|
        row = {
          "source" => "xquik.ruby.search",
          "query" => query,
          "tweet_id" => tweet.id,
          "text" => tweet.text,
          "author_id" => tweet.author&.id,
          "author_username" => tweet.author&.username,
          "author_name" => tweet.author&.name,
          "created_at" => tweet.created_at,
          "like_count" => tweet.like_count || 0,
          "reply_count" => tweet.reply_count || 0,
          "retweet_count" => tweet.retweet_count || 0,
          "quote_count" => tweet.quote_count || 0,
          "view_count" => tweet.view_count || 0,
          "bookmark_count" => tweet.bookmark_count || 0,
          "is_note_tweet" => tweet.is_note_tweet || false,
          "page_index" => page_index,
          "page_cursor" => page_cursor,
          "next_cursor" => page.next_cursor == "" ? nil : page.next_cursor,
          "has_next_page" => page.has_next_page
        }

        csv << headers.map { |header| row.fetch(header) }
        jsonl.puts(JSON.generate(row))
      end

      break unless page.has_next_page && page.next_cursor != ""

      cursor = page.next_cursor
      page_index += 1
    end
  end
end
```

The generated params map directly to the REST endpoint:

<CardGroup cols={2}>
  <Card title="q" icon="search">
    Ruby keyword `q` maps to REST `q`. Use it for the required X search query with keywords, handles, hashtags, or operators.
  </Card>

  <Card title="limit" icon="list">
    Ruby keyword `limit` maps to REST `limit`. Use it as a 1 to 200 upper bound for a bounded pull. If `page.has_next_page` is true, keep the same `q`, filters, `query_type`, and `limit` when you continue with `page.next_cursor`.
  </Card>

  <Card title="cursor" icon="arrow-right">
    Ruby keyword `cursor` maps to REST `cursor`. Pass the opaque cursor from `page.next_cursor` to request the next page.
  </Card>

  <Card title="since_time" icon="clock">
    Ruby keyword `since_time` maps to REST `sinceTime`. Use it as the ISO 8601 lower time bound.
  </Card>

  <Card title="until_time" icon="clock">
    Ruby keyword `until_time` maps to REST `untilTime`. Use it as the ISO 8601 upper time bound.
  </Card>

  <Card title="query_type" icon="sliders-horizontal">
    Ruby keyword `query_type` maps to REST `queryType`. Use `:Latest` for chronological results or `:Top` for engagement-ranked results.
  </Card>
</CardGroup>

## Returned Data & Handoff

`client.x.tweets.search` returns `XTwitterScraper::PaginatedTweets`:

<CardGroup cols={2}>
  <Card title="page.tweets" icon="message-square-text">
    JSON field `tweets`. Contains `SearchTweet` records with `id`, `text`, optional `author`, `created_at`, `like_count`, `reply_count`, `retweet_count`, `quote_count`, `bookmark_count`, `view_count`, and `is_note_tweet` when available.
  </Card>

  <Card title="page.has_next_page" icon="circle-check">
    Ruby field `page.has_next_page`. JSON field `has_next_page`. Tells your worker whether another page exists.
  </Card>

  <Card title="page.next_cursor" icon="arrow-right">
    JSON field `next_cursor`. Store it only when `page.has_next_page` is true. For bounded pulls that return fewer tweets than `limit`, pass it back as `cursor` with the same query, filters, `query_type`, and `limit`.
  </Card>
</CardGroup>

Project `page.tweets` into CSV rows for analysts and JSON Lines rows for queues and data lakes. Store `tweet_id`, `author_username`, engagement counts, `page_index`, `page_cursor`, `next_cursor`, and `has_next_page` in `xquik-tweet-search.jsonl` so workers can resume safely or load the same records into XLSX, CRM, warehouse, or agent workflows. For explicit `limit` pulls, resume with the same query, filters, `query_type`, and `limit`; only `cursor` changes.

## Workflow: Follower Export to CSV, JSON, or XLSX

Use this workflow when a Ruby app, Rails job, Sidekiq worker, or agent tool needs an owned follower list for a CRM import, warehouse load, analyst CSV file, XLSX workbook, or resumable JSON handoff. It calls `POST /extractions/estimate` through `client.extractions.estimate_cost`, creates the job with `client.extractions.run`, reads saved rows with `client.extractions.retrieve`, and downloads files with `client.extractions.export_results`.

<Info>
  `client.extractions.run` returns the queued `202 Accepted` receipt from `POST /extractions`: REST `id`, `toolType`, and `status: "running"` as Ruby `job.id`, `job.tool_type`, and `job.status`. Store `job.id` immediately, then poll `client.extractions.retrieve` before reading pages or calling `client.extractions.export_results`. Credit reservation happens after the job starts. If available credits changed since `estimate_cost`, the run can fetch only the affordable count before export or mark the job `failed` with `insufficient_credits`.
</Info>

```ruby theme={null}
require "json"
require "x_twitter_scraper"

client = XTwitterScraper::Client.new(
  api_key: ENV["X_TWITTER_SCRAPER_API_KEY"]
)
target_username = "username"

estimate = client.extractions.estimate_cost(
  tool_type: :follower_explorer,
  target_username: target_username
)

raise "Insufficient credits for follower export." unless estimate.allowed

job = client.extractions.run(
  tool_type: :follower_explorer,
  target_username: target_username
)

loop do
  status_page = client.extractions.retrieve(job.id, limit: 1)
  status = status_page.job[:status] || status_page.job["status"]

  break if status == "completed"

  raise "Follower export failed." if status == "failed"

  sleep 10
end

after = nil

File.open("xquik-followers.jsonl", "w") do |jsonl|
  loop do
    page = client.extractions.retrieve(job.id, limit: 1000, after: after)

    page.results.each do |row|
      jsonl.puts(JSON.generate(row))
    end

    break unless page.has_more && page.next_cursor.to_s != ""

    after = page.next_cursor
  end
end

csv_response = client.extractions.export_results(job.id, format_: :csv)
csv_response.rewind
File.binwrite("xquik-followers.csv", csv_response.read)

json_response = client.extractions.export_results(job.id, format_: :json)
json_response.rewind
File.binwrite("xquik-followers.json", json_response.read)

xlsx_response = client.extractions.export_results(job.id, format_: :xlsx)
xlsx_response.rewind
File.binwrite("xquik-followers.xlsx", xlsx_response.read)
```

`follower_explorer` requires `target_username`. Persist `job.id`, `target_username`, `estimate.estimated_results`, and `estimate.source` before polling so a Sidekiq retry, Rails job retry, or worker restart can resume the same follower export. `client.extractions.retrieve` returns `results`, `has_more`, and `next_cursor`; pass `next_cursor` back as `after` when you need stored JSON pages before exporting files. Map exported `User ID` or row `xUserId` as the CRM unique key. Use `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 a Ruby app, Rails job, Sidekiq worker, or agent tool needs every reply under one tweet as a saved extraction, JSON Lines handoff, or CSV/JSON/XLSX file export.

Reuse the polling, JSON Lines pagination, and export structure from the follower workflow. Only the tool type, target field, and filenames change:

```ruby theme={null}
target_tweet_id = "1893704267862470862"

estimate = client.extractions.estimate_cost(
  tool_type: :reply_extractor,
  target_tweet_id: target_tweet_id
)

raise "Insufficient credits for reply extraction." unless estimate.allowed

job = client.extractions.run(
  tool_type: :reply_extractor,
  target_tweet_id: target_tweet_id
)

# Run the same polling and JSONL pagination loops from the follower workflow.
# Set the JSON Lines destination to File.open("xquik-replies.jsonl", "w").

csv_response = client.extractions.export_results(job.id, format_: :csv)
csv_response.rewind
File.binwrite("xquik-replies.csv", csv_response.read)

json_response = client.extractions.export_results(job.id, format_: :json)
json_response.rewind
File.binwrite("xquik-replies.json", json_response.read)

xlsx_response = client.extractions.export_results(job.id, format_: :xlsx)
xlsx_response.rewind
File.binwrite("xquik-replies.xlsx", xlsx_response.read)
```

`reply_extractor` requires `target_tweet_id`. `client.extractions.retrieve` returns `results`, `has_more`, and `next_cursor`; the shared pagination loop passes `next_cursor` back as `after`. Use `xquik-replies.jsonl` for queue replay or warehouse loads, `xquik-replies.json` for app ingestion, `xquik-replies.csv` for CRM import, and `xquik-replies.xlsx` for analyst handoff. `client.extractions.export_results` supports `:csv`, `:json`, and `:xlsx` for file handoff. Cost: 1 credit per reply extracted or returned.

## Workflow: Post Media Tweets and DM Attachments

Use this workflow when a Ruby app, Rails job, Sidekiq worker, or agent service needs to post a media-backed tweet, reply with media, or send one uploaded media item in a DM.

Tweet and reply media posts use public media URLs directly on `POST /x/tweets`. Send up to 4 image URLs or exactly 1 MP4 video URL up to 100 MB. Do not mix video with other media. Do not upload first when the media URL is already public.

Use `client.request` to capture the durable write action. Send a unique `Idempotency-Key`, store `id`, `request.hash`, `billing`, `result`, and `statusUrl`, then poll while `terminal` is false. Retry only when `safeToRetry` is true, using a new key.

```ruby theme={null}
def create_tweet_handoff(client, payload)
  response = client.request(
    method: :post,
    path: "x/tweets",
    body: payload
  )

  result = response.fetch(:result, {}) || {}
  {
    "status" => response.fetch(:status),
    "terminal" => response.fetch(:terminal),
    "safe_to_retry" => response.fetch(:safeToRetry),
    "write_action_id" => response.fetch(:id),
    "request_hash" => response.dig(:request, :hash),
    "tweet_id" => result[:id] || response[:tweetId],
    "charged" => response.dig(:billing, :charged),
    "charged_credits" => response.dig(:billing, :chargedCredits),
    "poll" => response.fetch(:terminal) ? nil : response.fetch(:statusUrl)
  }
end

tweet_handoff = create_tweet_handoff(
  client,
  {
    account: "@username",
    text: "New demo video is live.",
    media: ["https://example.com/product-demo.mp4"]
  }
)

puts(JSON.generate(tweet_handoff))
```

To post an image reply, add `reply_to_tweet_id`:

```ruby theme={null}
reply_handoff = create_tweet_handoff(
  client,
  {
    account: "@username",
    text: "Here is the requested screenshot.",
    reply_to_tweet_id: "1893704267862470862",
    media: ["https://example.com/export-preview.png"]
  }
)

puts(JSON.generate(reply_handoff))
```

For DM attachments, upload the local file first and pass the returned `media.media_id` as the only `media_ids` item:

```ruby theme={null}
File.open("./handoff.png", "rb") do |file|
  media = client.x.media.upload(
    account: "@username",
    file: file
  )

  dm = client.x.dm.send_(
    "44196397",
    account: "@username",
    text: "Here is the asset.",
    media_ids: [media.media_id]
  )

  dm_handoff = {
    message_id: dm.message_id,
    media_id: media.media_id,
    user_id: "44196397",
    account: "@username",
    status: "sent"
  }

  puts(JSON.generate(dm_handoff))
end
```

Store `write_action_id` and `charged_credits`, then poll [Get Write Action Status](/api-reference/x-write/get-write-action-status) before retrying a pending tweet or reply. `client.x.tweets.create` returns `tweet.tweet_id` for confirmed-only flows. `client.x.media.upload` returns `media.media_id` for DM attachments, and `client.x.dm.send_` returns `dm.message_id` for support tickets, CRM records, queue jobs, 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`, `account`, `user_id`, and send status instead of full DM bodies. Leave generated `reply_to_message_id` unset even if SDK params expose it; the REST endpoint rejects DM reply threading.

Text-only tweet and reply writes cost 30 credits. Tweet media adds 2 credits per started MB across attached files. Uploading media costs 10 credits, and sending the DM costs 10 credits. Do not pass uploaded `media.media_id` values to `client.x.tweets.create`; that method uses `media` with public media URLs.

## Cost, Limits & Retries

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`. Read calls are rate-limited, and `429` responses include `Retry-After`.

The client retries connection errors, timeouts, 408, 409, 429, and 5xx responses by default. Handle `XTwitterScraper::Errors::RateLimitError` with backoff, and fix 400, 401, 403, 404, or 422 responses before retrying.

## Error Handling

All SDK errors inherit from `XTwitterScraper::Errors::APIError`.

<CardGroup cols={2}>
  <Card title="400 Bad Request" icon="triangle-alert">
    Throws `BadRequestError`.
  </Card>

  <Card title="401 Unauthenticated" icon="lock">
    Throws `AuthenticationError`.
  </Card>

  <Card title="403 Permission Denied" icon="shield">
    Throws `PermissionDeniedError`.
  </Card>

  <Card title="404 Not Found" icon="circle-x">
    Throws `NotFoundError`.
  </Card>

  <Card title="422 Unprocessable Entity" icon="file-warning">
    Throws `UnprocessableEntityError`.
  </Card>

  <Card title="429 Rate Limited" icon="gauge">
    Throws `RateLimitError`.
  </Card>

  <Card title="5xx Server Error" icon="server">
    Throws `InternalServerError`.
  </Card>
</CardGroup>

```ruby theme={null}
begin
  account = client.account.retrieve
rescue XTwitterScraper::Errors::APIConnectionError => e
  warn("Connection failed: #{e.cause}")
rescue XTwitterScraper::Errors::APIStatusError => e
  warn("HTTP #{e.status}")
end
```

## Pagination

Paginated responses expose fields such as `has_next_page`. Pass the endpoint's cursor fields when requesting more pages.

```ruby theme={null}
page = client.x.tweets.search(q: "xquik", limit: 20)
puts("More results are available") if page.has_next_page
```

## Webhooks & References

* [Search Tweets](/api-reference/x/search-tweets)
* [Create Tweet](/api-reference/x-write/create-tweet)
* [Upload Media](/api-reference/x-write/upload-media)
* [Send Direct Message](/api-reference/x-write/send-dm)
* [Create Monitor](/api-reference/monitors/create)
* [Webhook Overview](/webhooks/overview)
* [Verify HMAC Signatures](/webhooks/verification)
* [REST API Overview](/api-reference/overview)
* [Source Repository](https://github.com/Xquik-dev/x-twitter-scraper-ruby)
