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 PHP SDK for PHP 8.1+ applications with named parameters, generated models, typed exceptions, retries, and file upload helpers. It is useful when a PHP app or worker 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 a queue, CRM, or reporting pipeline.

Install

composer require xquik/x-twitter-scraper
If your project needs the GitHub source directly, add the repository in composer.json as described in the source repository.

Authenticate

export X_TWITTER_SCRAPER_API_KEY="xq_YOUR_KEY_HERE"

Basic Example

Search tweets and write durable JSON Lines handoff rows:
<?php

use XTwitterScraper\Client;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: 'xq_YOUR_KEY_HERE'
);

$page = $client->x->tweets->search(
  q: 'from:xquikcom webhook OR SDK',
  limit: 10,
);

foreach ($page->tweets as $tweet) {
  $row = [
    'tweet_id' => $tweet->id,
    'text' => $tweet->text,
    'author_username' => $tweet->author?->username,
    'created_at' => $tweet->createdAt,
  ];

  echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL;
}

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

Use this workflow when a PHP worker, Laravel command, Symfony console command, or cron job needs tweet search results in a durable file for a queue, warehouse loader, analyst CSV export, XLSX workbook, or CRM enrichment step. $client->x->tweets->search() calls GET /x/tweets/search. The generated parameter model is TweetSearchParams, and the service method exposes the same request controls as named arguments: q, limit, cursor, sinceTime, untilTime, and queryType.
<?php

use XTwitterScraper\Client;
use XTwitterScraper\PaginatedTweets;
use XTwitterScraper\SearchTweet;
use XTwitterScraper\X\Tweets\TweetSearchParams\QueryType;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$query = 'from:xquikcom webhook OR SDK';
$cursor = null;
$pageIndex = 0;
$headers = [
  'source',
  'query',
  'tweet_id',
  'text',
  'author_id',
  'author_username',
  'author_name',
  'created_at',
  'like_count',
  'reply_count',
  'retweet_count',
  'quote_count',
  'view_count',
  'bookmark_count',
  'is_note_tweet',
  'page_index',
  'page_cursor',
  'next_cursor',
  'has_next_page',
];
$jsonlHandle = fopen('xquik-tweet-search.jsonl', 'wb');
$csvHandle = fopen('xquik-tweet-search.csv', 'wb');

if (false === $jsonlHandle || false === $csvHandle) {
  throw new RuntimeException('Could not open tweet search handoff files');
}

try {
  fputcsv($csvHandle, $headers);

  do {
    $pageCursor = $cursor;

    /** @var PaginatedTweets $page */
    $page = $client->x->tweets->search(
      q: $query,
      cursor: $cursor,
      queryType: QueryType::LATEST,
    );

    foreach ($page->tweets as $tweet) {
      /** @var SearchTweet $tweet */
      $row = [
        'source' => 'xquik.php.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->nextCursor ? null : $page->nextCursor,
        'has_next_page' => $page->hasNextPage,
      ];
      $csvRow = [];
      foreach ($headers as $header) {
        $csvRow[] = $row[$header];
      }

      fputcsv($csvHandle, $csvRow);
      fwrite(
        $jsonlHandle,
        json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
      );
    }

    $cursor = $page->hasNextPage ? $page->nextCursor : null;
    $pageIndex++;
  } while (null !== $cursor && '' !== $cursor);
} finally {
  fclose($jsonlHandle);
  fclose($csvHandle);
}

Request Mapping

q

PHP argument q maps to REST q. Use it for an X search query such as from:username, a keyword, hashtag, or boolean operator query.

limit

PHP argument limit maps to REST limit. Use it for a bounded request from 1 to 200. Omit it for cursor loops.

cursor

PHP argument cursor maps to REST cursor. Pass the opaque cursor from $page->nextCursor to request the next page.

sinceTime

PHP argument sinceTime maps to REST sinceTime. Use it as the ISO 8601 lower bound for tweet creation time.

untilTime

PHP argument untilTime maps to REST untilTime. Use it as the ISO 8601 upper bound for tweet creation time.

queryType

PHP argument 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 array, $page->hasNextPage to decide whether another page exists, and $page->nextCursor as the checkpoint for the next request. Each SearchTweet includes typed properties such as $id, $text, $author, $createdAt, $likeCount, $replyCount, $retweetCount, $quoteCount, $bookmarkCount, $viewCount, 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, timeouts, 408, 409, 429, and 5xx responses 2 times by default. For long-running workers, pass requestOptions: ['maxRetries' => 5] and persist $page->nextCursor after each page.

Workflow: Follower Export to CSV, JSON, or XLSX

Use this workflow when a PHP worker, Laravel command, Symfony console command, or cron job needs an owned follower list for CRM import, warehouse loading, account scoring, analyst CSV, XLSX 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.
<?php

use XTwitterScraper\Client;
use XTwitterScraper\Extractions\ExtractionEstimateCostParams\ToolType as EstimateToolType;
use XTwitterScraper\Extractions\ExtractionExportResultsParams\Format as ExportFormat;
use XTwitterScraper\Extractions\ExtractionRunParams\ToolType as RunToolType;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$targetUsername = 'xquikcom';
$estimate = $client->extractions->estimateCost(
  toolType: EstimateToolType::FOLLOWER_EXPLORER,
  targetUsername: $targetUsername,
);

if (!$estimate->allowed) {
  throw new RuntimeException("Follower export requires {$estimate->creditsRequired} credits.");
}

$job = $client->extractions->run(
  toolType: RunToolType::FOLLOWER_EXPLORER,
  targetUsername: $targetUsername,
);

$completed = false;

for ($attempt = 0; $attempt < 120; $attempt++) {
  $statusPage = $client->extractions->retrieve($job->id, limit: 1);
  $status = $statusPage->job['status'] ?? null;

  if ('completed' === $status) {
    $completed = true;
    break;
  }

  if ('failed' === $status) {
    throw new RuntimeException('Follower export failed.');
  }

  sleep(10);
}

if (!$completed) {
  throw new RuntimeException('Follower export did not complete before timeout.');
}

$after = null;
$handle = fopen('xquik-followers.jsonl', 'wb');

if (false === $handle) {
  throw new RuntimeException('Could not open xquik-followers.jsonl');
}

try {
  do {
    $page = $client->extractions->retrieve($job->id, after: $after, limit: 1000);

    foreach ($page->results as $user) {
      fwrite(
        $handle,
        json_encode($user, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
      );
    }

    $after = $page->hasMore ? $page->nextCursor : null;
  } while (null !== $after && '' !== $after);
} finally {
  fclose($handle);
}

file_put_contents(
  'xquik-followers.csv',
  $client->extractions->exportResults($job->id, format: ExportFormat::CSV),
);
file_put_contents(
  'xquik-followers.json',
  $client->extractions->exportResults($job->id, format: ExportFormat::JSON),
);
file_put_contents(
  'xquik-followers.xlsx',
  $client->extractions->exportResults($job->id, format: ExportFormat::XLSX),
);
Persist $job->id, $targetUsername, $estimate->estimatedResults, and $estimate->source before polling so another queue worker can resume without rerunning the extraction. $client->extractions->retrieve() returns $page->results, $page->hasMore, and $page->nextCursor; pass $page->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 PHP 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 ToolType::REPLY_EXTRACTOR; reply_extractor requires targetTweetID. $client->extractions->retrieve() returns results, hasMore, and nextCursor for pagination. $client->extractions->exportResults() returns the completed job as a string and supports CSV, JSON, and XLSX export formats.
<?php

use XTwitterScraper\Client;
use XTwitterScraper\Extractions\ExtractionEstimateCostParams\ToolType as EstimateToolType;
use XTwitterScraper\Extractions\ExtractionExportResultsParams\Format as ExportFormat;
use XTwitterScraper\Extractions\ExtractionRunParams\ToolType as RunToolType;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$targetTweetID = '1893704267862470862';
$estimate = $client->extractions->estimateCost(
  toolType: EstimateToolType::REPLY_EXTRACTOR,
  targetTweetID: $targetTweetID,
);

if (!$estimate->allowed) {
  throw new RuntimeException('Insufficient credits for reply extraction.');
}

$job = $client->extractions->run(
  toolType: RunToolType::REPLY_EXTRACTOR,
  targetTweetID: $targetTweetID,
);

while (true) {
  $statusPage = $client->extractions->retrieve($job->id, limit: 1);
  $status = $statusPage->job['status'] ?? null;

  if ('completed' === $status) {
    break;
  }

  if ('failed' === $status) {
    throw new RuntimeException('Reply extraction failed.');
  }

  sleep(3);
}

$after = null;
$handle = fopen('xquik-replies.jsonl', 'wb');

if (false === $handle) {
  throw new RuntimeException('Could not open xquik-replies.jsonl');
}

try {
  do {
    $page = $client->extractions->retrieve($job->id, after: $after, limit: 1000);

    foreach ($page->results as $reply) {
      fwrite(
        $handle,
        json_encode($reply, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
      );
    }

    $after = $page->hasMore ? $page->nextCursor : null;
  } while (null !== $after && '' !== $after);
} finally {
  fclose($handle);
}

file_put_contents(
  'xquik-replies.csv',
  $client->extractions->exportResults($job->id, format: ExportFormat::CSV),
);
file_put_contents(
  'xquik-replies.json',
  $client->extractions->exportResults($job->id, format: ExportFormat::JSON),
);
file_put_contents(
  'xquik-replies.xlsx',
  $client->extractions->exportResults($job->id, format: ExportFormat::XLSX),
);
Cost: 1 credit per reply extracted or returned. 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. 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 PHP app needs to publish a media tweet, reply with media, or send a direct message with an uploaded local file. POST /x/tweets accepts 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 its $media->mediaID only for the one-item mediaIDs array on $client->x->dm->send(). Use $client->x->tweets->raw->create() when a worker must branch on the REST 202 x_write_unconfirmed response. The generated $client->x->tweets->create() helper is still useful when your flow only continues after a confirmed $tweet->tweetID.
<?php

use XTwitterScraper\Client;
use XTwitterScraper\Core\FileParam;

/**
 * @param array<string,mixed> $payload
 *
 * @return array<string,mixed>
 */
function createTweetHandoff(Client $client, array $payload): array
{
  $response = $client->x->tweets->raw->create(params: $payload);

  if (202 === $response->getStatusCode()) {
    $body = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

    if ('x_write_unconfirmed' === ($body['error'] ?? null)) {
      return [
        'status' => $body['status'],
        'write_action_id' => $body['writeActionId'],
        'charged' => $body['charged'],
        'charged_credits' => $body['chargedCredits'],
        'retryable' => $body['retryable'],
        'poll' => 'GET /x/write-actions/{id}',
      ];
    }
  }

  $tweet = $response->parse();

  return [
    'status' => 'success',
    'tweet_id' => $tweet->tweetID,
    'charged' => $tweet->charged,
    'charged_credits' => $tweet->chargedCredits,
  ];
}

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$tweetHandoff = createTweetHandoff($client, [
  'account' => '@xquikcom',
  'text' => 'Shipping the weekly X API video.',
  'media' => ['https://static.example.com/reports/x-api-export.mp4'],
]);

$replyHandoff = createTweetHandoff($client, [
  'account' => '@xquikcom',
  'text' => 'Here is the chart behind the update.',
  'media' => ['https://static.example.com/reports/reply-chart.png'],
  'replyToTweetID' => $tweetHandoff['tweet_id'] ?? '1893704267862470862',
]);

$localFile = fopen('handoff.png', 'rb');

if (false === $localFile) {
  throw new RuntimeException('Could not open handoff.png');
}

try {
  $media = $client->x->media->upload(
    account: '@xquikcom',
    file: FileParam::fromResource($localFile),
  );
} finally {
  fclose($localFile);
}

$dm = $client->x->dm->send(
  '44196397',
  account: '@xquikcom',
  text: 'Here is the requested asset.',
  mediaIDs: [$media->mediaID],
);

$dmHandoff = [
  'message_id' => $dm->messageID,
  'media_id' => $media->mediaID,
];

fwrite(
  STDOUT,
  json_encode(
    [
      'tweet_handoff' => $tweetHandoff,
      'reply_handoff' => $replyHandoff,
      'dm_handoff' => $dmHandoff,
    ],
    JSON_THROW_ON_ERROR
  ) . PHP_EOL
);
Store write_action_id and charged_credits, then poll Get Write Action Status before retrying a pending tweet or reply. Store tweet_id on the CMS, queue job, or agent state after confirmed writes. Store $dm->messageID on the support ticket or CRM note. 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 media with public media URLs.

Error Handling

The SDK throws subclasses of XTwitterScraper\Core\Exceptions\APIException.

400 Bad Request

Throws BadRequestException.

401 Unauthorized

Throws AuthenticationException.

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.
<?php

use XTwitterScraper\Core\Exceptions\APIConnectionException;
use XTwitterScraper\Core\Exceptions\APIStatusException;

try {
  $account = $client->account->retrieve();
} catch (APIConnectionException $e) {
  fwrite(STDERR, "Connection failed\n");
} catch (APIStatusException $e) {
  fwrite(STDERR, $e->getMessage() . PHP_EOL);
}

Pagination

Paginated responses expose fields such as hasNextPage. Pass the endpoint’s cursor fields when requesting more pages.
<?php

$page = $client->x->tweets->search(q: 'xquik', limit: 20);

if ($page->hasNextPage) {
  fwrite(STDERR, "More results are available\n");
}

Webhooks & References

Last modified on May 20, 2026