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

# Java SDK

> Use the Xquik Java SDK to search tweets, export JSON Lines, CSV, or XLSX, post media tweets, upload media, send DMs, and run JVM X API workers.

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

Use the Java SDK for generated JVM models, builders, sync calls, async calls, typed exceptions, retries, and file upload helpers in Java 8+ applications. It is useful when a JVM service, Spring worker, queue consumer, or cron job needs to search tweets, scrape tweets to JSON Lines, CSV, or XLSX, export followers, monitor tweets, post media tweets, upload media, send direct messages, or hand X API data to analytics and CRM systems.

## Install

<Note>
  Maven Central publication is pending. Build from source until the `com.x_twitter_scraper.api:x-twitter-scraper-java` artifact resolves in Maven Central.
</Note>

```bash theme={null}
git clone https://github.com/Xquik-dev/x-twitter-scraper-java.git
cd x-twitter-scraper-java
./gradlew build
```

For local Maven testing:

```bash theme={null}
./gradlew publishToMavenLocal -PpublishLocal
```

Before restoring Maven Central install snippets, verify:

```bash theme={null}
curl -f https://repo1.maven.org/maven2/com/x_twitter_scraper/api/x-twitter-scraper-java/maven-metadata.xml
```

## Authenticate

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

`XTwitterScraperOkHttpClient.fromEnv()` 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:

```java theme={null}
import com.fasterxml.jackson.databind.ObjectMapper;
import com.x_twitter_scraper.api.client.XTwitterScraperClient;
import com.x_twitter_scraper.api.client.okhttp.XTwitterScraperOkHttpClient;
import com.x_twitter_scraper.api.models.PaginatedTweets;
import com.x_twitter_scraper.api.models.SearchTweet;
import com.x_twitter_scraper.api.models.x.tweets.TweetSearchParams;
import java.util.LinkedHashMap;
import java.util.Map;

XTwitterScraperClient client = XTwitterScraperOkHttpClient.fromEnv();
ObjectMapper objectMapper = new ObjectMapper();

TweetSearchParams params = TweetSearchParams.builder()
    .q("from:username webhook OR SDK")
    .limit(10L)
    .build();

PaginatedTweets page = client.x().tweets().search(params);

for (SearchTweet tweet : page.tweets()) {
    Map<String, Object> row = new LinkedHashMap<>();
    row.put("tweet_id", tweet.id());
    row.put("text", tweet.text());
    row.put("author_username", tweet.author().map(SearchTweet.Author::username).orElse(null));
    row.put("created_at", tweet.createdAt().orElse(null));

    System.out.println(objectMapper.writeValueAsString(row));
}
```

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

Use this workflow when a Java service, Spring Batch job, scheduled worker, or queue consumer needs tweet search results in a durable handoff file for a data lake, CRM enrichment step, analyst CSV export, XLSX workbook, or downstream processor.

`client.x().tweets().search` calls `GET /x/tweets/search`. Build `TweetSearchParams` with the same query parameters the REST API accepts: `.q()`, `.limit()`, `.cursor()`, `.sinceTime()`, `.untilTime()`, and `.queryType()`.

```java theme={null}
import com.fasterxml.jackson.databind.ObjectMapper;
import com.x_twitter_scraper.api.client.XTwitterScraperClient;
import com.x_twitter_scraper.api.client.okhttp.XTwitterScraperOkHttpClient;
import com.x_twitter_scraper.api.models.PaginatedTweets;
import com.x_twitter_scraper.api.models.SearchTweet;
import com.x_twitter_scraper.api.models.x.tweets.TweetSearchParams;
import com.x_twitter_scraper.api.models.x.tweets.TweetSearchParams.QueryType;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

XTwitterScraperClient client = XTwitterScraperOkHttpClient.fromEnv();
ObjectMapper objectMapper = new ObjectMapper();

String query = "from:username webhook OR SDK";
String cursor = null;
int pageIndex = 0;
List<String> headers = Arrays.asList(
    "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"
);

try (
    BufferedWriter jsonlWriter = Files.newBufferedWriter(
        Paths.get("xquik-tweet-search.jsonl"),
        StandardCharsets.UTF_8
    );
    BufferedWriter csvWriter = Files.newBufferedWriter(
        Paths.get("xquik-tweet-search.csv"),
        StandardCharsets.UTF_8
    )
) {
    writeCsvRow(csvWriter, headers);

    do {
        String pageCursor = cursor;
        TweetSearchParams.Builder builder = TweetSearchParams.builder()
            .q(query)
            .queryType(QueryType.LATEST);

        if (cursor != null && !cursor.isEmpty()) {
            builder.cursor(cursor);
        }

        PaginatedTweets page = client.x().tweets().search(builder.build());

        for (SearchTweet tweet : page.tweets()) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("source", "xquik.java.search");
            row.put("query", query);
            row.put("tweet_id", tweet.id());
            row.put("text", tweet.text());
            row.put("author_id", tweet.author().map(SearchTweet.Author::id).orElse(null));
            row.put("author_username", tweet.author().map(SearchTweet.Author::username).orElse(null));
            row.put("author_name", tweet.author().map(SearchTweet.Author::name).orElse(null));
            row.put("created_at", tweet.createdAt().orElse(null));
            row.put("like_count", tweet.likeCount().orElse(0L));
            row.put("reply_count", tweet.replyCount().orElse(0L));
            row.put("retweet_count", tweet.retweetCount().orElse(0L));
            row.put("quote_count", tweet.quoteCount().orElse(0L));
            row.put("view_count", tweet.viewCount().orElse(0L));
            row.put("bookmark_count", tweet.bookmarkCount().orElse(0L));
            row.put("is_note_tweet", tweet.isNoteTweet().orElse(false));
            row.put("page_index", pageIndex);
            row.put("page_cursor", pageCursor);
            row.put("next_cursor", page.hasNextPage() ? page.nextCursor() : null);
            row.put("has_next_page", page.hasNextPage());

            List<Object> csvRow = new ArrayList<>();
            for (String header : headers) {
                csvRow.add(row.get(header));
            }

            jsonlWriter.write(objectMapper.writeValueAsString(row));
            jsonlWriter.newLine();
            writeCsvRow(csvWriter, csvRow);
        }

        pageIndex++;
        cursor = page.hasNextPage() ? page.nextCursor() : null;
    } while (cursor != null && !cursor.isEmpty());
}

static void writeCsvRow(BufferedWriter writer, List<?> values) throws IOException {
    for (int index = 0; index < values.size(); index++) {
        if (index > 0) {
            writer.write(",");
        }

        Object value = values.get(index);
        String cell = value == null ? "" : String.valueOf(value);
        writer.write("\"" + cell.replace("\"", "\"\"") + "\"");
    }

    writer.newLine();
}
```

### Request Mapping

<CardGroup cols={2}>
  <Card title=".q()" icon="search">
    Java builder method `.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">
    Java builder method `.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">
    Java builder method `.cursor()` maps to REST `cursor`. Pass the opaque cursor from `page.nextCursor()` to request the next page.
  </Card>

  <Card title=".sinceTime()" icon="clock">
    Java builder method `.sinceTime()` maps to REST `sinceTime`. Use it as the ISO 8601 lower bound for tweet creation time.
  </Card>

  <Card title=".untilTime()" icon="clock">
    Java builder method `.untilTime()` maps to REST `untilTime`. Use it as the ISO 8601 upper bound for tweet creation time.
  </Card>

  <Card title=".queryType()" icon="sliders-horizontal">
    Java builder method `.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 list, `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 the requested `.limit()`, pass `page.nextCursor()` back as `.cursor()` with the same query, filters, `QueryType`, and `.limit()`.

Each `SearchTweet` includes generated accessors 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 supports `withOptions()` for retry and request settings. 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 JVM worker needs an owned follower list for CRM import, warehouse loading, account scoring, analyst CSV, XLSX workbook delivery, or a resumable JSON handoff.

`client.extractions().estimateCost` maps to `POST /extractions/estimate`, `client.extractions().run` maps to `POST /extractions`, `client.extractions().retrieve` maps to `GET /extractions/{id}`, and `client.extractions().exportResults` maps to `GET /extractions/{id}/export`. For follower exports, `follower_explorer` requires `targetUsername`.

<Info>
  `client.extractions().run` returns the queued `202 Accepted` receipt from `POST /extractions`: REST `id`, `toolType`, and `status: "running"` as Java `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>

```java theme={null}
import com.fasterxml.jackson.databind.ObjectMapper;
import com.x_twitter_scraper.api.client.XTwitterScraperClient;
import com.x_twitter_scraper.api.client.okhttp.XTwitterScraperOkHttpClient;
import com.x_twitter_scraper.api.core.JsonValue;
import com.x_twitter_scraper.api.core.http.HttpResponse;
import com.x_twitter_scraper.api.models.extractions.ExtractionEstimateCostParams;
import com.x_twitter_scraper.api.models.extractions.ExtractionEstimateCostResponse;
import com.x_twitter_scraper.api.models.extractions.ExtractionExportResultsParams;
import com.x_twitter_scraper.api.models.extractions.ExtractionRetrieveParams;
import com.x_twitter_scraper.api.models.extractions.ExtractionRetrieveResponse;
import com.x_twitter_scraper.api.models.extractions.ExtractionRunParams;
import com.x_twitter_scraper.api.models.extractions.ExtractionRunResponse;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

XTwitterScraperClient client = XTwitterScraperOkHttpClient.fromEnv();
ObjectMapper objectMapper = new ObjectMapper();
String targetUsername = "username";

ExtractionEstimateCostResponse estimate = client.extractions().estimateCost(
    ExtractionEstimateCostParams.builder()
        .toolType(ExtractionEstimateCostParams.ToolType.FOLLOWER_EXPLORER)
        .targetUsername(targetUsername)
        .build()
);

if (!estimate.allowed()) {
    throw new IllegalStateException(
        "Follower export requires " + estimate.creditsRequired() + " credits."
    );
}

ExtractionRunResponse job = client.extractions().run(
    ExtractionRunParams.builder()
        .toolType(ExtractionRunParams.ToolType.FOLLOWER_EXPLORER)
        .targetUsername(targetUsername)
        .build()
);

boolean completed = false;
for (int attempt = 0; attempt < 120; attempt++) {
    ExtractionRetrieveResponse statusPage = client.extractions().retrieve(job.id());
    JsonValue statusValue = statusPage.job()._additionalProperties().get("status");
    String status = statusValue == null ? "unknown" : statusValue.asString().orElse("unknown");

    if ("completed".equals(status)) {
        completed = true;
        break;
    }

    if ("failed".equals(status)) {
        throw new IllegalStateException("Follower export failed.");
    }

    Thread.sleep(10_000L);
}

if (!completed) {
    throw new IllegalStateException("Follower export did not complete before timeout.");
}

String after = null;
try (BufferedWriter writer = Files.newBufferedWriter(
    Paths.get("xquik-followers.jsonl"),
    StandardCharsets.UTF_8
)) {
    do {
        ExtractionRetrieveParams.Builder pageParams = ExtractionRetrieveParams.builder()
            .id(job.id())
            .limit(1000L);

        if (after != null && !after.isEmpty()) {
            pageParams.after(after);
        }

        ExtractionRetrieveResponse page = client.extractions().retrieve(pageParams.build());

        for (ExtractionRetrieveResponse.Result row : page.results()) {
            writer.write(objectMapper.writeValueAsString(row._additionalProperties()));
            writer.newLine();
        }

        after = page.hasMore() ? page.nextCursor().orElse(null) : null;
    } while (after != null && !after.isEmpty());
}

try (HttpResponse export = client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.CSV)
        .build()
)) {
    Files.copy(
        export.body(),
        Paths.get("xquik-followers.csv"),
        StandardCopyOption.REPLACE_EXISTING
    );
}

try (HttpResponse export = client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.JSON)
        .build()
)) {
    Files.copy(
        export.body(),
        Paths.get("xquik-followers.json"),
        StandardCopyOption.REPLACE_EXISTING
    );
}

try (HttpResponse export = client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.XLSX)
        .build()
)) {
    Files.copy(
        export.body(),
        Paths.get("xquik-followers.xlsx"),
        StandardCopyOption.REPLACE_EXISTING
    );
}
```

Persist `job.id()`, `targetUsername`, `estimate.estimatedResults()`, and `estimate.source()` before polling so a queue retry can resume without rerunning the extraction. `client.extractions().retrieve` returns `results()`, `hasMore()`, and `nextCursor()`; pass `nextCursor()` back as `after` to page through large follower lists. 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 Java worker needs every reply to a campaign, support thread, launch post, or incident update as a durable file. `reply_extractor` requires `targetTweetId`; estimate first, run the job, poll `retrieve`, then export the completed job as CSV, JSON, or XLSX.

`client.extractions().estimateCost` maps to `POST /extractions/estimate`, `client.extractions().run` maps to `POST /extractions`, `client.extractions().retrieve` maps to `GET /extractions/{id}`, and `client.extractions().exportResults` maps to `GET /extractions/{id}/export`.

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

```java theme={null}
String targetTweetId = "1893704267862470862";

ExtractionEstimateCostResponse estimate = client.extractions().estimateCost(
    ExtractionEstimateCostParams.builder()
        .toolType(ExtractionEstimateCostParams.ToolType.REPLY_EXTRACTOR)
        .targetTweetId(targetTweetId)
        .build()
);

ExtractionRunResponse job = client.extractions().run(
    ExtractionRunParams.builder()
        .toolType(ExtractionRunParams.ToolType.REPLY_EXTRACTOR)
        .targetTweetId(targetTweetId)
        .build()
);

ExtractionRetrieveParams.Builder pageParams = ExtractionRetrieveParams.builder()
    .id(job.id())
    .limit(1000L);

if (after != null && !after.isEmpty()) {
    pageParams.after(after);
}

ExtractionRetrieveResponse page = client.extractions().retrieve(pageParams.build());
```

Persist `job.id()` before polling so a queue retry can resume with `client.extractions().retrieve(job.id())` and repeat `client.extractions().exportResults(...)` after completion. Stream `page.results()` to `Paths.get("xquik-replies.jsonl")`. Keep `page.nextCursor()` as the checkpoint when you stream replies to JSON Lines. Pass it back as `after` on the next `ExtractionRetrieveParams.Builder pageParams`.

Keep the same `estimate.allowed()` credit branch from the follower export workflow before calling `run`.

Export the completed job with the same helper used above:

```java theme={null}
try (HttpResponse export = client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.CSV)
        .build()
)) {
    Files.copy(
        export.body(),
        Paths.get("xquik-replies.csv"),
        StandardCopyOption.REPLACE_EXISTING
    );
}
```

Repeat the export with `ExtractionExportResultsParams.Format.JSON` to write `Paths.get("xquik-replies.json")` and `ExtractionExportResultsParams.Format.XLSX` to write `Paths.get("xquik-replies.xlsx")`. 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.

## Workflow: Post Media Tweets and DM Attachments

Use this workflow when a JVM service 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 `.addMedia()` or `.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 DM `.addMediaId()` handoff.

```java theme={null}
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.x_twitter_scraper.api.client.XTwitterScraperClient;
import com.x_twitter_scraper.api.client.okhttp.XTwitterScraperOkHttpClient;
import com.x_twitter_scraper.api.core.http.HttpResponseFor;
import com.x_twitter_scraper.api.models.x.dm.DmSendParams;
import com.x_twitter_scraper.api.models.x.dm.DmSendResponse;
import com.x_twitter_scraper.api.models.x.media.MediaUploadParams;
import com.x_twitter_scraper.api.models.x.media.MediaUploadResponse;
import com.x_twitter_scraper.api.models.x.tweets.TweetCreateParams;
import com.x_twitter_scraper.api.models.x.tweets.TweetCreateResponse;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

XTwitterScraperClient client = XTwitterScraperOkHttpClient.fromEnv();
ObjectMapper objectMapper = new ObjectMapper();

static Map<String, Object> createTweetHandoff(
    Map<String, Object> payload,
    Map<String, Object> base
) {
    Map<String, Object> handoff = new LinkedHashMap<>();
    Map<String, Object> billing = (Map<String, Object>) payload.get("billing");
    Map<String, Object> request = (Map<String, Object>) payload.get("request");
    Map<String, Object> result = (Map<String, Object>) payload.get("result");
    boolean terminal = Boolean.TRUE.equals(payload.get("terminal"));

    handoff.put("status", payload.get("status"));
    handoff.put("terminal", terminal);
    handoff.put("safe_to_retry", payload.get("safeToRetry"));
    handoff.put("write_action_id", payload.get("id"));
    handoff.put("request_hash", request.get("hash"));
    handoff.put("tweet_id", result == null ? payload.get("tweetId") : result.get("id"));
    handoff.put("charged", billing.get("charged"));
    handoff.put("charged_credits", billing.get("chargedCredits"));
    handoff.put("poll", terminal ? null : payload.get("statusUrl"));

    handoff.putAll(base);
    return handoff;
}

Map<String, Object> tweetPayload;
try (HttpResponseFor<TweetCreateResponse> response = client.x().tweets().withRawResponse().create(
    TweetCreateParams.builder()
        .account("@username")
        .text("Shipping the weekly X API video.")
        .addMedia("https://static.example.com/reports/x-api-export.mp4")
        .build()
)) {
    tweetPayload = objectMapper.readValue(
        response.body(),
        new TypeReference<Map<String, Object>>() {}
    );
}

Map<String, Object> tweetBase = new LinkedHashMap<>();
tweetBase.put("account", "@username");
tweetBase.put("media", List.of("https://static.example.com/reports/x-api-export.mp4"));
Map<String, Object> tweetHandoff = createTweetHandoff(tweetPayload, tweetBase);

Map<String, Object> replyPayload;
try (HttpResponseFor<TweetCreateResponse> response = client.x().tweets().withRawResponse().create(
    TweetCreateParams.builder()
        .account("@username")
        .text("Here is the chart behind the update.")
        .addMedia("https://static.example.com/reports/reply-chart.png")
        .replyToTweetId("1893704267862470862")
        .build()
)) {
    replyPayload = objectMapper.readValue(
        response.body(),
        new TypeReference<Map<String, Object>>() {}
    );
}

Map<String, Object> replyBase = new LinkedHashMap<>();
replyBase.put("account", "@username");
replyBase.put("reply_to_tweet_id", "1893704267862470862");
replyBase.put("media", List.of("https://static.example.com/reports/reply-chart.png"));
Map<String, Object> replyHandoff = createTweetHandoff(replyPayload, replyBase);

MediaUploadResponse media = client.x().media().upload(
    MediaUploadParams.builder()
        .account("@username")
        .file(Paths.get("handoff.png"))
        .build()
);

DmSendResponse dm = client.x().dm().send(
    "44196397",
    DmSendParams.builder()
        .account("@username")
        .text("Here is the requested asset.")
        .addMediaId(media.mediaId())
        .build()
);

Map<String, Object> dmHandoff = new LinkedHashMap<>();
dmHandoff.put("status", "sent");
dmHandoff.put("message_id", dm.messageId());
dmHandoff.put("media_id", media.mediaId());
dmHandoff.put("account", "@username");
dmHandoff.put("user_id", "44196397");

System.out.println(objectMapper.writeValueAsString(tweetHandoff));
System.out.println(objectMapper.writeValueAsString(replyHandoff));
System.out.println(objectMapper.writeValueAsString(dmHandoff));
```

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 Java SDK throws unchecked exceptions.

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

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

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

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

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

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

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

Connection and I/O failures use `XTwitterScraperIoException`. All SDK exceptions inherit from `XTwitterScraperException`.

## Pagination

Paginated responses expose generated fields such as `hasNextPage`. Use the endpoint cursor fields documented in the API reference when requesting additional pages.

```java theme={null}
if (tweets.hasNextPage()) {
    System.err.println("More results are available");
}
```

## Webhooks & References

* [Search Tweets](/api-reference/x/search-tweets)
* [Create Tweet](/api-reference/x-write/create-tweet)
* [Get Write Action Status](/api-reference/x-write/get-write-action-status)
* [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-java)
