Skip to main content

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.

Use the Kotlin SDK for generated JVM models, Kotlin nullable values, builders, sync calls, async calls, typed exceptions, retries, and file upload helpers. It is useful when a JVM service, Ktor worker, Spring job, queue consumer, or cron task 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

Maven Central publication is pending. Build from source until the com.x_twitter_scraper.api:x-twitter-scraper-kotlin artifact resolves in Maven Central.
git clone https://github.com/Xquik-dev/x-twitter-scraper-kotlin.git
cd x-twitter-scraper-kotlin
./gradlew build
For local Maven testing:
./gradlew publishToMavenLocal -PpublishLocal
Before restoring Maven Central install snippets, verify:
curl -f https://repo1.maven.org/maven2/com/x_twitter_scraper/api/x-twitter-scraper-kotlin/maven-metadata.xml

Authenticate

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

val client: XTwitterScraperClient = XTwitterScraperOkHttpClient.fromEnv()
val objectMapper = ObjectMapper()

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

val page: PaginatedTweets = client.x().tweets().search(params)

for (tweet: SearchTweet in page.tweets()) {
    val row = linkedMapOf<String, Any?>(
        "tweet_id" to tweet.id(),
        "text" to tweet.text(),
        "author_username" to tweet.author()?.username(),
        "created_at" to tweet.createdAt(),
    )

    println(objectMapper.writeValueAsString(row))
}

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

Use this workflow when a Kotlin service, scheduled worker, queue consumer, or agent 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().
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.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths

val client: XTwitterScraperClient = XTwitterScraperOkHttpClient.fromEnv()
val objectMapper = ObjectMapper()

val query = "from:xquikcom webhook OR SDK"
var cursor: String? = null
var pageIndex = 0
val headers = listOf(
    "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",
)

Files.newBufferedWriter(Paths.get("xquik-tweet-search.jsonl"), StandardCharsets.UTF_8).use { jsonlWriter ->
    Files.newBufferedWriter(Paths.get("xquik-tweet-search.csv"), StandardCharsets.UTF_8).use { csvWriter ->
        writeCsvRow(csvWriter, headers)

        do {
            val pageCursor = cursor
            val builder = TweetSearchParams.builder()
                .q(query)
                .queryType(QueryType.LATEST)

            if (!cursor.isNullOrEmpty()) {
                builder.cursor(cursor)
            }

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

            for (tweet: SearchTweet in page.tweets()) {
                val author = tweet.author()
                val row = linkedMapOf<String, Any?>(
                    "source" to "xquik.kotlin.search",
                    "query" to query,
                    "tweet_id" to tweet.id(),
                    "text" to tweet.text(),
                    "author_id" to author?.id(),
                    "author_username" to author?.username(),
                    "author_name" to author?.name(),
                    "created_at" to tweet.createdAt(),
                    "like_count" to (tweet.likeCount() ?: 0L),
                    "reply_count" to (tweet.replyCount() ?: 0L),
                    "retweet_count" to (tweet.retweetCount() ?: 0L),
                    "quote_count" to (tweet.quoteCount() ?: 0L),
                    "view_count" to (tweet.viewCount() ?: 0L),
                    "bookmark_count" to (tweet.bookmarkCount() ?: 0L),
                    "is_note_tweet" to (tweet.isNoteTweet() ?: false),
                    "page_index" to pageIndex,
                    "page_cursor" to pageCursor,
                    "next_cursor" to if (page.hasNextPage()) page.nextCursor() else null,
                    "has_next_page" to page.hasNextPage(),
                )

                jsonlWriter.write(objectMapper.writeValueAsString(row))
                jsonlWriter.newLine()
                writeCsvRow(csvWriter, headers.map { header -> row[header] })
            }

            pageIndex++
            cursor = if (page.hasNextPage()) page.nextCursor() else null
        } while (!cursor.isNullOrEmpty())
    }
}

fun writeCsvRow(writer: BufferedWriter, values: List<Any?>) {
    writer.write(
        values.joinToString(",") { value ->
            val cell = value?.toString() ?: ""
            "\"" + cell.replace("\"", "\"\"") + "\""
        }
    )
    writer.newLine()
}

Request Mapping

.q()

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

.limit()

Kotlin builder method .limit() maps to REST limit. Use it for a bounded request from 1 to 200. Omit it for cursor loops.

.cursor()

Kotlin builder method .cursor() maps to REST cursor. Pass the opaque cursor from page.nextCursor() to request the next page.

.sinceTime()

Kotlin builder method .sinceTime() maps to REST sinceTime. Use it as the ISO 8601 lower bound for tweet creation time.

.untilTime()

Kotlin builder method .untilTime() maps to REST untilTime. Use it as the ISO 8601 upper bound for tweet creation time.

.queryType()

Kotlin builder method .queryType() maps to REST queryType. Use QueryType.LATEST for chronological search or QueryType.TOP for engagement-ranked search.

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. 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 longer workers, set an explicit retry limit and persist page.nextCursor() after each page.

Workflow: Follower Export to CSV, JSON, or XLSX

Use this workflow when a Kotlin 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.
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.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.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption

val client: XTwitterScraperClient = XTwitterScraperOkHttpClient.fromEnv()
val objectMapper = ObjectMapper()
val targetUsername = "xquikcom"

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

if (!estimate.allowed()) {
    error("Follower export requires ${estimate.creditsRequired()} credits.")
}

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

var completed = false
var attempts = 0
while (!completed && attempts < 120) {
    val statusPage = client.extractions().retrieve(job.id())
    val status = statusPage.job()._additionalProperties()["status"]?.asString() ?: "unknown"

    if (status == "completed") {
        completed = true
    } else if (status == "failed") {
        error("Follower export failed.")
    } else {
        Thread.sleep(10_000L)
    }

    attempts += 1
}

if (!completed) {
    error("Follower export did not complete before timeout.")
}

var after: String? = null
Files.newBufferedWriter(Paths.get("xquik-followers.jsonl"), StandardCharsets.UTF_8).use { writer ->
    do {
        val pageParams = ExtractionRetrieveParams.builder()
            .id(job.id())
            .limit(1000L)

        if (!after.isNullOrEmpty()) {
            pageParams.after(after)
        }

        val page: ExtractionRetrieveResponse = client.extractions().retrieve(pageParams.build())

        for (row in page.results()) {
            writer.write(objectMapper.writeValueAsString(row._additionalProperties()))
            writer.newLine()
        }

        after = if (page.hasMore()) page.nextCursor() else null
    } while (!after.isNullOrEmpty())
}

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

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

client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.XLSX)
        .build()
).use { export ->
    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 Kotlin 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.
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.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.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths
import java.nio.file.StandardCopyOption

val client: XTwitterScraperClient = XTwitterScraperOkHttpClient.fromEnv()
val objectMapper = ObjectMapper()
val targetTweetId = "1893704267862470862"

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

if (!estimate.allowed()) {
    error("Reply export requires ${estimate.creditsRequired()} credits.")
}

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

var completed = false
var attempts = 0
while (!completed && attempts < 120) {
    val statusPage = client.extractions().retrieve(job.id())
    val status = statusPage.job()._additionalProperties()["status"]?.asString() ?: "unknown"

    if (status == "completed") {
        completed = true
    } else if (status == "failed") {
        error("Reply export failed.")
    } else {
        Thread.sleep(10_000L)
    }

    attempts += 1
}

if (!completed) {
    error("Reply export did not complete before timeout.")
}

var after: String? = null
Files.newBufferedWriter(Paths.get("xquik-replies.jsonl"), StandardCharsets.UTF_8).use { writer ->
    do {
        val pageParams = ExtractionRetrieveParams.builder()
            .id(job.id())
            .limit(1000L)

        if (!after.isNullOrEmpty()) {
            pageParams.after(after)
        }

        val page: ExtractionRetrieveResponse = client.extractions().retrieve(pageParams.build())

        for (row in page.results()) {
            writer.write(objectMapper.writeValueAsString(row._additionalProperties()))
            writer.newLine()
        }

        after = if (page.hasMore()) page.nextCursor() else null
    } while (!after.isNullOrEmpty())
}

client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.CSV)
        .build()
).use { export ->
    Files.copy(export.body(), Paths.get("xquik-replies.csv"), StandardCopyOption.REPLACE_EXISTING)
}

client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.JSON)
        .build()
).use { export ->
    Files.copy(export.body(), Paths.get("xquik-replies.json"), StandardCopyOption.REPLACE_EXISTING)
}

client.extractions().exportResults(
    ExtractionExportResultsParams.builder()
        .id(job.id())
        .format(ExtractionExportResultsParams.Format.XLSX)
        .build()
).use { export ->
    Files.copy(export.body(), Paths.get("xquik-replies.xlsx"), StandardCopyOption.REPLACE_EXISTING)
}
Persist job.id() before polling so a queue retry can resume with client.extractions().retrieve(job.id()) and repeat client.extractions().exportResults(...) after completion. Keep page.nextCursor() as the checkpoint when you stream replies to JSON Lines. 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 Kotlin 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.
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

val client: XTwitterScraperClient = XTwitterScraperOkHttpClient.fromEnv()
val objectMapper = ObjectMapper()

fun createTweetHandoff(
    payload: Map<String, Any?>,
    base: Map<String, Any?>,
): Map<String, Any?> {
    val handoff = linkedMapOf<String, Any?>()

    if (
        payload["error"] == "x_write_unconfirmed" &&
        payload["status"] == "pending_confirmation"
    ) {
        handoff["status"] = "pending_confirmation"
        handoff["write_action_id"] = payload["writeActionId"]
        handoff["charged"] = payload["charged"]
        handoff["charged_credits"] = payload["chargedCredits"]
        handoff["retryable"] = payload["retryable"]
        handoff["poll"] = "GET /x/write-actions/{id}"
    } else {
        handoff["status"] = "posted"
        handoff["tweet_id"] = payload["tweetId"]
        handoff["charged"] = payload["charged"]
        handoff["charged_credits"] = payload["chargedCredits"]
        if (payload.containsKey("writeActionId")) {
            handoff["write_action_id"] = payload["writeActionId"]
        }
    }

    handoff.putAll(base)
    return handoff
}

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

val tweetHandoff = createTweetHandoff(
    tweetPayload,
    linkedMapOf(
        "account" to "@xquikcom",
        "media" to listOf("https://static.example.com/reports/x-api-export.mp4"),
    ),
)

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

val replyHandoff = createTweetHandoff(
    replyPayload,
    linkedMapOf(
        "account" to "@xquikcom",
        "reply_to_tweet_id" to "1893704267862470862",
        "media" to listOf("https://static.example.com/reports/reply-chart.png"),
    ),
)

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

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

val dmHandoff = linkedMapOf(
    "status" to "sent",
    "message_id" to dm.messageId(),
    "media_id" to media.mediaId(),
    "account" to "@xquikcom",
    "user_id" to "44196397",
)

println(objectMapper.writeValueAsString(tweetHandoff))
println(objectMapper.writeValueAsString(replyHandoff))
println(objectMapper.writeValueAsString(dmHandoff))
The generated Kotlin response model covers confirmed tweetId responses. Use client.x().tweets().withRawResponse().create(...) when a write worker must branch on the REST 202 x_write_unconfirmed response, store writeActionId and chargedCredits, and poll Get Write Action Status before sending another write. Store tweetHandoff, replyHandoff, and dmHandoff on the CMS, support ticket, queue job, CRM note, or agent state before scheduling follow-up work. Text-only tweet and reply writes cost 10 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.mediaId() values to client.x().tweets().create; that method uses public media URLs through media.

Error Handling

The Kotlin SDK uses the same generated exception hierarchy as the Java SDK.

400 Bad Request

Throws BadRequestException.

401 Unauthorized

Throws UnauthorizedException.

403 Permission Denied

Throws PermissionDeniedException.

404 Not Found

Throws NotFoundException.

422 Validation Error

Throws UnprocessableEntityException.

429 Rate Limited

Throws RateLimitException.

5xx Server Error

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

Pagination

Paginated responses expose generated fields such as hasNextPage. Use endpoint cursor parameters from the API reference for follow-up requests.
if (tweets.hasNextPage()) {
    System.err.println("More results are available")
}

Webhooks & References

Last modified on May 20, 2026