> ## 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.

# C# SDK

> Use the Xquik C# SDK to search tweets, export tweet replies to JSON Lines, CSV, or XLSX, post media tweets, upload media, send DMs, and run .NET X API workers.

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

Use the C# SDK for typed .NET Standard 2.0+ access to Xquik from services, workers, console tools, and ASP.NET backends. It is useful when a .NET job needs to search tweets, scrape tweets or replies to JSON Lines, CSV, or XLSX, export followers, monitor tweets, post media tweets, upload media, send direct messages, or hand X API data to queues, CRMs, and reporting systems.

## Install

```bash theme={null}
dotnet add package XTwitterScraper
```

## Authenticate

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

`new XTwitterScraperClient()` reads `X_TWITTER_SCRAPER_API_KEY`, `X_TWITTER_SCRAPER_BEARER_TOKEN`, and `X_TWITTER_SCRAPER_BASE_URL`.

## Basic Example

Search tweets and write durable JSON Lines handoff rows:

```csharp theme={null}
using System.Text.Json;
using XTwitterScraper;
using XTwitterScraper.Models;
using XTwitterScraper.Models.X.Tweets;

XTwitterScraperClient client = new();

TweetSearchParams parameters = new()
{
    Q = "from:username webhook OR SDK",
    Limit = 10,
};

PaginatedTweets page = await client.X.Tweets.Search(parameters);

foreach (SearchTweet tweet in page.Tweets)
{
    var row = new
    {
        tweet_id = tweet.ID,
        text = tweet.Text,
        author_username = tweet.Author?.Username,
        created_at = tweet.CreatedAt,
    };

    await Console.Out.WriteLineAsync(JsonSerializer.Serialize(row));
}
```

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

Use this workflow when a .NET worker, console job, or ASP.NET background service needs tweet search results in a durable handoff format for a queue, data lake, analyst CSV export, XLSX workbook, or CRM enrichment step.

`client.X.Tweets.Search` calls `GET /x/tweets/search`. Build a `TweetSearchParams` object with the same query parameters the REST API accepts: `Q`, `Limit`, `Cursor`, `SinceTime`, `UntilTime`, and `QueryType`.

```csharp theme={null}
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using XTwitterScraper;
using XTwitterScraper.Models;
using XTwitterScraper.Models.X.Tweets;

XTwitterScraperClient client = new();

string query = "from:username webhook OR SDK";
string? cursor = null;
int pageIndex = 0;
string[] headers = new[]
{
    "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",
};

using StreamWriter jsonlWriter = File.CreateText("xquik-tweet-search.jsonl");
using StreamWriter csvWriter = File.CreateText("xquik-tweet-search.csv");
await WriteCsvRow(csvWriter, headers);

do
{
    string? pageCursor = cursor;
    PaginatedTweets page = await client.X.Tweets.Search(
        new TweetSearchParams
        {
            Q = query,
            Cursor = cursor,
            QueryType = QueryType.Latest,
        }
    );

    foreach (SearchTweet tweet in page.Tweets)
    {
        Dictionary<string, object?> row = new()
        {
            ["source"] = "xquik.csharp.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.CreatedAt,
            ["like_count"] = tweet.LikeCount ?? 0,
            ["reply_count"] = tweet.ReplyCount ?? 0,
            ["retweet_count"] = tweet.RetweetCount ?? 0,
            ["quote_count"] = tweet.QuoteCount ?? 0,
            ["view_count"] = tweet.ViewCount ?? 0,
            ["bookmark_count"] = tweet.BookmarkCount ?? 0,
            ["is_note_tweet"] = tweet.IsNoteTweet ?? false,
            ["page_index"] = pageIndex,
            ["page_cursor"] = pageCursor,
            ["next_cursor"] = page.HasNextPage ? page.NextCursor : null,
            ["has_next_page"] = page.HasNextPage,
        };

        await jsonlWriter.WriteLineAsync(JsonSerializer.Serialize(row));
        await WriteCsvRow(csvWriter, headers.Select(header => row[header]));
    }

    cursor = page.HasNextPage ? page.NextCursor : null;
    pageIndex++;
} while (!string.IsNullOrEmpty(cursor));

static async Task WriteCsvRow(StreamWriter writer, IEnumerable<object?> values)
{
    static string Escape(object? value)
    {
        string cell = value?.ToString() ?? "";
        return "\"" + cell.Replace("\"", "\"\"") + "\"";
    }

    await writer.WriteLineAsync(string.Join(",", values.Select(Escape)));
}
```

### Request Mapping

<CardGroup cols={2}>
  <Card title="Q" icon="search">
    C# property `Q` maps to REST `q`. Use it for an X search query such as `from:username`, a keyword, hashtag, or boolean operator query.
  </Card>

  <Card title="Limit" icon="list">
    C# property `Limit` maps to REST `limit`. Use it as a 1 to 200 upper bound for a bounded pull. If `page.HasNextPage` is true, keep the same `Q`, filters, `QueryType`, and `Limit` when you continue with `page.NextCursor`.
  </Card>

  <Card title="Cursor" icon="arrow-right">
    C# property `Cursor` maps to REST `cursor`. Pass the opaque cursor from `page.NextCursor` to request the next page.
  </Card>

  <Card title="SinceTime" icon="clock">
    C# property `SinceTime` maps to REST `sinceTime`. Use it as the ISO 8601 lower bound for tweet creation time.
  </Card>

  <Card title="UntilTime" icon="clock">
    C# property `UntilTime` maps to REST `untilTime`. Use it as the ISO 8601 upper bound for tweet creation time.
  </Card>

  <Card title="QueryType" icon="sliders-horizontal">
    C# property `QueryType` maps to REST `queryType`. Use `QueryType.Latest` for chronological search or `QueryType.Top` for engagement-ranked search.
  </Card>
</CardGroup>

### Returned Data & Handoff

`client.X.Tweets.Search` returns `PaginatedTweets`. Use `page.Tweets` for the tweet array, `page.HasNextPage` to decide whether another page exists, and `page.NextCursor` as the checkpoint for the next request. For bounded pulls that return fewer tweets than `Limit`, pass `page.NextCursor` back as `Cursor` with the same query, filters, `QueryType`, and `Limit`.

Each `SearchTweet` includes typed properties such as `ID`, `Text`, `Author`, `CreatedAt`, `LikeCount`, `ReplyCount`, `RetweetCount`, `QuoteCount`, `ViewCount`, `BookmarkCount`, and `IsNoteTweet` when available. Project `page.Tweets` into JSON Lines and CSV rows with `tweet_id`, `author_username`, engagement counts, `page_index`, `page_cursor`, `next_cursor`, and `has_next_page` so workers can resume safely or load the same records into XLSX, CRM, warehouse, or agent workflows.

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`. The SDK retries connection errors, 408, 409, 429, and 5xx responses 2 times by default. For explicit `Limit` pulls, resume with the same query, filters, `QueryType`, and `Limit`; only `Cursor` changes.

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

Use this workflow when a .NET worker, console job, or ASP.NET background service needs an owned follower list for a CRM import, warehouse load, analyst CSV file, XLSX workbook, or resumable JSON handoff.

`client.Extractions.EstimateCost` and `client.Extractions.Run` map to the extraction workflow. For follower exports, use `ExtractionEstimateCostParamsToolType.FollowerExplorer` and `ExtractionRunParamsToolType.FollowerExplorer`; `follower_explorer` requires `TargetUsername`. `client.Extractions.Retrieve` returns `Results`, `HasMore`, and `NextCursor` for pagination. `client.Extractions.ExportResults` returns an `HttpResponse`; read CSV and JSON as strings, and copy XLSX from the response stream.

<Info>
  `client.Extractions.Run` returns the queued `202 Accepted` receipt from `POST /extractions`: REST `id`, `toolType`, and `status: "running"` as C# `job.ID`, `job.ToolType`, and `job.Status`. Store `job.ID` immediately, then poll `client.Extractions.Retrieve` before reading pages or calling `client.Extractions.ExportResults`. Credit reservation happens after the job starts. If available credits changed since `EstimateCost`, the run can fetch only the affordable count before export or mark the job `failed` with `insufficient_credits`.
</Info>

```csharp theme={null}
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using XTwitterScraper;
using XTwitterScraper.Core;
using XTwitterScraper.Models.Extractions;

XTwitterScraperClient client = new();

string targetUsername = "username";
ExtractionEstimateCostResponse estimate = await client.Extractions.EstimateCost(
    new ExtractionEstimateCostParams
    {
        ToolType = ExtractionEstimateCostParamsToolType.FollowerExplorer,
        TargetUsername = targetUsername,
    }
);

if (!estimate.Allowed)
{
    throw new InvalidOperationException("Insufficient credits for follower export.");
}

ExtractionRunResponse job = await client.Extractions.Run(
    new ExtractionRunParams
    {
        ToolType = ExtractionRunParamsToolType.FollowerExplorer,
        TargetUsername = targetUsername,
    }
);

while (true)
{
    ExtractionRetrieveResponse statusPage = await client.Extractions.Retrieve(
        job.ID,
        new ExtractionRetrieveParams { Limit = 1 }
    );

    string? status = statusPage.Job.TryGetValue("status", out JsonElement statusValue)
        ? statusValue.GetString()
        : null;

    if (status == "completed")
    {
        break;
    }

    if (status == "failed")
    {
        throw new InvalidOperationException("Follower export failed.");
    }

    await Task.Delay(TimeSpan.FromSeconds(10));
}

string? after = null;
using StreamWriter writer = File.CreateText("xquik-followers.jsonl");

do
{
    ExtractionRetrieveResponse page = await client.Extractions.Retrieve(
        job.ID,
        new ExtractionRetrieveParams { After = after, Limit = 1000 }
    );

    foreach (var follower in page.Results)
    {
        await writer.WriteLineAsync(JsonSerializer.Serialize(follower));
    }

    after = page.HasMore ? page.NextCursor : null;
} while (!string.IsNullOrEmpty(after));

using HttpResponse csvResponse = await client.Extractions.ExportResults(
    job.ID,
    new ExtractionExportResultsParams { Format = Format.Csv }
);
await File.WriteAllTextAsync("xquik-followers.csv", await csvResponse.ReadAsString());

using HttpResponse jsonResponse = await client.Extractions.ExportResults(
    job.ID,
    new ExtractionExportResultsParams { Format = Format.Json }
);
await File.WriteAllTextAsync("xquik-followers.json", await jsonResponse.ReadAsString());

using HttpResponse xlsxResponse = await client.Extractions.ExportResults(
    job.ID,
    new ExtractionExportResultsParams { Format = Format.Xlsx }
);
using Stream xlsxStream = await xlsxResponse.ReadAsStream();
using FileStream xlsxFile = File.Create("xquik-followers.xlsx");
await xlsxStream.CopyToAsync(xlsxFile);
```

Cost: 1 credit per follower extracted or returned. Persist `job.ID`, `targetUsername`, `estimate.EstimatedResults`, and `estimate.Source` before polling so a queue retry, Windows service restart, or worker restart can resume the same follower export. Keep `page.NextCursor` as the checkpoint when you stream followers to JSON Lines, and 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. Exports are free after the extraction job exists.

## Workflow: Tweet Replies to CSV, JSON, or XLSX

Use this workflow when a .NET worker needs every reply on a public tweet in a durable file for moderation review, customer support triage, analyst export, or a warehouse loader.

`client.Extractions.EstimateCost` and `client.Extractions.Run` map to the extraction workflow. For tweet replies, use `ExtractionEstimateCostParamsToolType.ReplyExtractor` and `ExtractionRunParamsToolType.ReplyExtractor`; `reply_extractor` requires `TargetTweetID`. `client.Extractions.Retrieve` returns `Results`, `HasMore`, and `NextCursor` for pagination. `client.Extractions.ExportResults` returns an `HttpResponse`; read CSV and JSON as strings, and copy XLSX from the response stream.

Reuse the follower export loop above. Change only the required target, tool type, and output names:

```csharp theme={null}
string targetTweetID = "1893704267862470862";
ExtractionEstimateCostResponse estimate = await client.Extractions.EstimateCost(
    new ExtractionEstimateCostParams
    {
        ToolType = ExtractionEstimateCostParamsToolType.ReplyExtractor,
        TargetTweetID = targetTweetID,
    }
);

ExtractionRunResponse job = await client.Extractions.Run(
    new ExtractionRunParams
    {
        ToolType = ExtractionRunParamsToolType.ReplyExtractor,
        TargetTweetID = targetTweetID,
    }
);

using StreamWriter writer = File.CreateText("xquik-replies.jsonl");
ExtractionRetrieveResponse page = await client.Extractions.Retrieve(
    job.ID,
    new ExtractionRetrieveParams { After = after, Limit = 1000 }
);

foreach (var reply in page.Results)
{
    await writer.WriteLineAsync(JsonSerializer.Serialize(reply));
}

using HttpResponse csvResponse = await client.Extractions.ExportResults(
    job.ID,
    new ExtractionExportResultsParams { Format = Format.Csv }
);
await File.WriteAllTextAsync("xquik-replies.csv", await csvResponse.ReadAsString());
```

Keep the same `estimate.Allowed` credit branch from the follower export workflow before calling `Run`. Store `job.ID` on the queue job, ticket, or warehouse batch before polling so another worker can resume with `client.Extractions.Retrieve`. Keep `page.NextCursor` as the checkpoint when you stream replies to JSON Lines. Pass it back as `After` on the next `ExtractionRetrieveParams` call.

Repeat the export with `new ExtractionExportResultsParams { Format = Format.Json }` and `File.WriteAllTextAsync("xquik-replies.json", await jsonResponse.ReadAsString())`. For XLSX, use `new ExtractionExportResultsParams { Format = Format.Xlsx }`, `File.Create("xquik-replies.xlsx")`, and stream-copy the response body. 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. Cost: 1 credit per reply extracted or returned.

## Workflow: Post Media Tweets and DM Attachments

Use this workflow when a .NET worker needs to publish a media tweet, reply with media, or send a direct message with an uploaded local file. `client.X.Tweets.Create` maps to `POST /x/tweets`; pass public media URLs through `Media`. Send up to 4 image URLs or exactly 1 MP4 video URL up to 100 MB, and do not mix video with other media. For replies, set `ReplyToTweetID` to the parent tweet ID. `client.X.Media.Upload` maps to `POST /x/media`; use `media.MediaID` only for the one-item `MediaIds` handoff on `client.X.Dm.Send`.

```csharp theme={null}
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using XTwitterScraper;
using XTwitterScraper.Core;
using XTwitterScraper.Models.X.Dm;
using XTwitterScraper.Models.X.Media;
using XTwitterScraper.Models.X.Tweets;

XTwitterScraperClient client = new();
string parentTweetID = "1893704267862470862";

using HttpResponse<TweetCreateResponse> tweetResponse =
    await client.X.Tweets.WithRawResponse.Create(
        new TweetCreateParams
        {
            Account = "@username",
            Text = "Shipping the weekly X API video.",
            Media = new[] { "https://static.example.com/reports/x-api-export.mp4" },
        }
    );

Dictionary<string, object?> tweetHandoff = await CreateTweetHandoff(
    tweetResponse,
    new Dictionary<string, object?>
    {
        ["account"] = "@username",
        ["media_url"] = "https://static.example.com/reports/x-api-export.mp4",
    }
);

using HttpResponse<TweetCreateResponse> replyResponse =
    await client.X.Tweets.WithRawResponse.Create(
        new TweetCreateParams
        {
            Account = "@username",
            Text = "Here is the chart behind the update.",
            Media = new[] { "https://static.example.com/reports/reply-chart.png" },
            ReplyToTweetID = parentTweetID,
        }
    );

Dictionary<string, object?> replyHandoff = await CreateTweetHandoff(
    replyResponse,
    new Dictionary<string, object?>
    {
        ["account"] = "@username",
        ["reply_to_tweet_id"] = parentTweetID,
        ["media_url"] = "https://static.example.com/reports/reply-chart.png",
    }
);

await using FileStream localFile = File.OpenRead("handoff.png");
MediaUploadResponse media = await client.X.Media.Upload(
    new MediaUploadParams
    {
        Account = "@username",
        File = localFile,
    }
);

DmSendResponse dm = await client.X.Dm.Send(
    "44196397",
    new DmSendParams
    {
        Account = "@username",
        Text = "Here is the requested asset.",
        MediaIds = new[] { media.MediaID },
    }
);

Dictionary<string, object?> dmHandoff = new()
{
    ["message_id"] = dm.MessageID,
    ["media_id"] = media.MediaID,
    ["user_id"] = "44196397",
    ["account"] = "@username",
};

await Console.Out.WriteLineAsync(JsonSerializer.Serialize(tweetHandoff));
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(replyHandoff));
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(dmHandoff));

static async Task<Dictionary<string, object?>> CreateTweetHandoff(
    HttpResponse<TweetCreateResponse> response,
    Dictionary<string, object?> row
)
{
    string body = await response.ReadAsString();
    using JsonDocument document = JsonDocument.Parse(body);
    JsonElement payload = document.RootElement;

    JsonElement billing = payload.GetProperty("billing");
    JsonElement result = payload.GetProperty("result");
    bool terminal = payload.GetProperty("terminal").GetBoolean();

    row["status"] = payload.GetProperty("status").GetString();
    row["terminal"] = terminal;
    row["safe_to_retry"] = payload.GetProperty("safeToRetry").GetBoolean();
    row["write_action_id"] = payload.GetProperty("id").GetString();
    row["request_hash"] = payload.GetProperty("request").GetProperty("hash").GetString();
    row["tweet_id"] = result.ValueKind == JsonValueKind.Object
        ? result.GetProperty("id").GetString()
        : null;
    row["charged"] = billing.GetProperty("charged").GetBoolean();
    row["charged_credits"] = billing.GetProperty("chargedCredits").GetString();
    row["poll"] = terminal ? null : payload.GetProperty("statusUrl").GetString();
    if (row["tweet_id"] is null && payload.TryGetProperty("tweetId", out JsonElement tweetId))
    {
        row["tweet_id"] = tweetId.GetString();
    }
    return row;
}
```

Use `client.X.Tweets.WithRawResponse.Create` 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.

Keep DM body text in private systems. Shared logs and handoffs should store `message_id`, optional `media_id`, `account`, `user_id`, and lifecycle status. Leave `ReplyToMessageID` unset because the REST endpoint rejects DM reply threading.

Use public media URLs with `client.X.Tweets.Create`. Do not pass uploaded `media.MediaID` values to tweet creation.

## Error Handling

The C# SDK throws generated exception types for non-success responses and connection failures.

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

  <Card title="401 Unauthorized" icon="lock">
    Throws `XTwitterScraperUnauthorizedException`.
  </Card>

  <Card title="403 Forbidden" icon="shield">
    Throws `XTwitterScraperForbiddenException`.
  </Card>

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

  <Card title="422 Validation Error" icon="file-warning">
    Throws `XTwitterScraperUnprocessableEntityException`.
  </Card>

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

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

Use the [error handling guide](/guides/error-handling) for response body fields and retry recommendations.

## Pagination

Paginated responses expose generated fields such as `HasNextPage`. Pass cursor parameters from the previous response when an endpoint supports cursor pagination.

```csharp theme={null}
if (tweets.HasNextPage)
{
    await Console.Error.WriteLineAsync("More results are available");
}
```

## 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-csharp)
