> ## 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 for tweet search, exports & X automation

> 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. See examples.

<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 fits JVM services, Spring workers, queue consumers, and cron jobs. Search
tweets, export followers, monitor tweets, post media, upload files, or send
direct messages. Hand tweet, follower, reply, and profile rows to analytics or
CRM systems.

| Java worker task | SDK call | Durable checkpoint |
| - | - | - |
| Search tweets | `client.x().tweets().search` | Store `page.nextCursor()` after every page. |
| Estimate follower or reply exports | `client.extractions().estimateCost` | Store the estimate and `allowed()` decision. |
| Start an extraction | `client.extractions().run` | Save `job.id()` immediately. |
| Retrieve saved rows | `client.extractions().retrieve` | Keep `nextCursor()` while `hasMore()` is true. |
| Export CSV, JSON, or XLSX | `client.extractions().exportResults` | Record the job ID, format, and file destination. |
| Create a tweet | `client.x().tweets().withRawResponse().create` | Store the write action ID and terminal state. |

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

Export tweet search from Java services, Spring Batch, scheduled workers, or queues. Save files for data lakes, CRM enrichment, CSV, XLSX, or downstream processing.

`client.x().tweets().search` calls `GET /x/tweets/search`. Build `TweetSearchParams` with the REST query parameters: `.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`. Workers can then resume 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

Export followers for CRM, warehouses, account scoring, CSV, XLSX, or resumable JSON.

`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 cursor = 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 (cursor != null && !cursor.isEmpty()) {
            pageParams.cursor(cursor);
        }

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

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

        cursor = page.hasMore() ? page.nextCursor().orElse(null) : null;
    } while (cursor != null && !cursor.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
    );
}
```

Store `job.id()`, `targetUsername`, `estimate.estimatedResults()`, and `estimate.source()` before polling so retries can resume the extraction. `client.extractions().retrieve` returns `results()`, `hasMore()`, and `nextCursor()`. Pass `nextCursor()` back as `cursor` 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.

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 (cursor != null && !cursor.isEmpty()) {
    pageParams.cursor(cursor);
}

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

Store `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 while streaming replies to JSON Lines. Pass it back as `cursor` 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 to post a media tweet or reply, or to DM 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. 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`. 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.

`client.x().dm().deleteMessage` [deletes a DM](/api-reference/x-write/delete-dm).

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="triangle-alert">
    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 more 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)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.