# x-api-rs — Complete API Reference

> High-performance Twitter/X API Python client built with Rust + PyO3
> All I/O methods are async. All clients are created via async `create()` static methods.
> Current version: 1.0.6 (2026-03-29)

**Important:** This is a native Rust extension module (PyO3). There is no `__init__.py` — all modules are compiled into a single binary `.so`/`.pyd` file. Module discovery via static analysis of Python source files will not work; use `dir()` or the documentation below instead.

## Installation

```
pip install x-api-rs
```

---

## Twitter (Main Entry Point)

The unified client that provides access to all modules via attributes.

### Creation

```python
from x_api_rs import Twitter

# Async creation (REQUIRED — there is no synchronous constructor)
client = await Twitter.create(cookies, proxy_url=None, profile=None)
```

**Parameters:**
- `cookies` (str): Twitter account cookies string
- `proxy_url` (str, optional): Proxy server URL (e.g., "http://proxy:8080")
- `profile` (ClientProfile, optional): Per-account browser identity (TLS + HTTP headers bound together). None = Chrome 136 Windows x64 (default).

### Module Attributes

- `client.dm` → DMClient — Direct messaging
- `client.upload` → UploadClient — Media upload
- `client.posts` → PostsClient — Tweet operations
- `client.user` → UserClient — User profile management
- `client.inbox` → InboxClient — Inbox/conversations
- `client.communities` → CommunitiesClient — Community features
- `client.settings` → SettingsClient — Account privacy and sensitive content settings
- `client.search` → SearchClient — Search tweets, users, media, and Lists

### Methods

- `client.get_cookies() -> str` — Get current cookies string (sync)
- `client.validate_cookies() -> bool` — Check if cookies are valid (sync)
- `client.fingerprint_info() -> ProfileInfo` — Get the actual browser identity in use: browser/version/platform/user_agent (sync)
- `await Twitter.auth_token_to_cookies(auth_token, proxy_url=None) -> AuthTokenResult` — Convert auth_token to full cookies (async static)

### AuthTokenResult

| Attribute | Type | Description |
|-----------|------|-------------|
| cookies | str | Full cookies string |
| ct0 | str | CSRF Token |
| user_id | str | User ID |
| auth_token | str | Original auth_token |

---

## DM Module (Direct Messages)

### Access

```python
# Via Twitter client
result = await client.dm.send_message("user_id", "Hello!")

# Or standalone
from x_api_rs.dm import DMClient
dm_client = await DMClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### send_message(user_id, text, media_id=None) -> DMResult
Send a single DM (v1 REST API).

- `user_id` (str): Target user ID
- `text` (str): Message content (max 10000 chars)
- `media_id` (str, optional): Media ID from upload

#### send_message_v2(user_id, text, media_id=None) -> DMResult
Send a single DM (v2 GraphQL API). Supports image-only messages (pass empty string for text).

- `user_id` (str): Target user ID
- `text` (str): Message content (can be empty for image-only)
- `media_id` (str, optional): Media ID from upload

#### send_batch(user_ids, text, media_ids=None) -> BatchDMResult
Send same message to multiple users concurrently.

- `user_ids` (list[str]): Target user IDs
- `text` (str): Message content
- `media_ids` (list[str | None], optional): Per-user media IDs (length must match user_ids)

#### send_batch_v2(user_ids, text, media_ids=None) -> BatchDMResult
Batch send via v2 GraphQL API.

- Same parameters as send_batch

#### send_batch_with_custom_texts(user_ids, texts, media_ids=None) -> BatchDMResult
Send different messages to each user.

- `user_ids` (list[str]): Target user IDs
- `texts` (list[str]): Per-user message texts (length must match user_ids)
- `media_ids` (list[str | None], optional): Per-user media IDs

#### send_batch_with_custom_texts_v2(user_ids, texts, media_ids=None) -> BatchDMResult
Custom texts batch send via v2 GraphQL API.

- Same parameters as send_batch_with_custom_texts

### DM Functions

```python
from x_api_rs.dm import encode_message, decode_message, MediaInput
```

#### encode_message(message=None, media=None) -> str
Encode a message to Twitter API format (Base64).

- `message` (str, optional): Text content
- `media` (list[MediaInput], optional): Media items
- At least one of message or media must be provided

#### decode_message(encoded) -> DecodedMessage
Decode a Base64-encoded message.

- `encoded` (str): Base64 encoded message string

### DM Types

#### DMResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether the send succeeded |
| user_id | str | Target user ID |
| message | str | Status message |
| error_msg | str | Error message (empty on success) |
| http_status | int | HTTP status code |
| event_id | str \| None | DM event ID |
| media_id | str \| None | Media ID used |

#### BatchDMResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success_count | int | Number of successful sends |
| failure_count | int | Number of failed sends |
| results | list[DMResult] | Per-user results |

#### MediaInput

| Attribute | Type | Description |
|-----------|------|-------------|
| media_id | str | Media ID (from upload) |
| filename | str \| None | Optional filename |

#### DecodedMessage

| Attribute | Type | Description |
|-----------|------|-------------|
| text | str | Text content (empty for media-only) |
| media | list[MediaAttachment] | Media attachment list |
| has_media | bool | Whether there are media attachments (property) |
| has_text | bool | Whether there is text content (property) |

#### MediaAttachment

| Attribute | Type | Description |
|-----------|------|-------------|
| media_id | str | Media ID |
| filename | str \| None | Filename |

---

## Upload Module

### Access

```python
# Via Twitter client
result = await client.upload.image(image_bytes, "tweet_image")

# Or standalone
from x_api_rs.upload import UploadClient
upload_client = await UploadClient.create(cookies, proxy_url=None, profile=None)
```

### Media Categories

**Image categories:** `"tweet_image"`, `"dm_image"`, `"banner_image"`
**Video categories:** `"amplify_video"`, `"dm_video"`

### Methods

#### upload(data, media_category, options=None, *, proxy_override=None, use_proxy=True) -> UploadResult
Unified upload interface for images and videos.

- `data` (bytes): Media binary data
- `media_category` (str): Media category string
- `options` (UploadOptions, optional): Upload options (required for video: must include duration_ms). `options.proxy_override` / `options.use_proxy` are merged but keyword args take precedence.
- `proxy_override` (str, optional): Override proxy for this upload only (e.g. `"http://alt:8080"`). Ignored when `None`.
- `use_proxy` (bool): `True` = inherit client proxy (default); `False` = direct connection (no proxy) for this upload only.

#### image(image_bytes, media_category, *, proxy_override=None, use_proxy=True) -> UploadResult
Convenience method for image uploads. Rejects video categories.

- `image_bytes` (bytes): Image binary data
- `media_category` (str): Image category ("tweet_image", "dm_image", "banner_image")
- `proxy_override` (str, optional): Override proxy for this upload only.
- `use_proxy` (bool): `True` = inherit client proxy (default); `False` = direct connection for this upload only.

#### video(video_bytes, media_category, duration_ms, processing_timeout=None, *, proxy_override=None, use_proxy=True) -> UploadResult
Convenience method for video uploads. Rejects image categories.

- `video_bytes` (bytes): Video binary data
- `media_category` (str): Video category ("amplify_video", "dm_video")
- `duration_ms` (float): Video duration in milliseconds (required, must be > 0)
- `processing_timeout` (int, optional): Processing timeout in seconds (default 300)
- `proxy_override` (str, optional): Override proxy for this upload only.
- `use_proxy` (bool): `True` = inherit client proxy (default); `False` = direct connection for this upload only.

#### image_multiple_times(image_bytes, media_category, count, *, proxy_override=None, use_proxy=True) -> BatchUploadResult
Upload same image multiple times to get independent media_ids (adds random perturbation).

- `image_bytes` (bytes): Image binary data
- `media_category` (str): Media category
- `count` (int): Number of uploads (must be > 0)
- `proxy_override` (str, optional): Override proxy for all uploads in this batch.
- `use_proxy` (bool): `True` = inherit client proxy (default); `False` = direct connection for all uploads.

#### set_media_metadata(media_id, metadata) -> None
Set content warning / AI disclosure / Grok-edit-block on an already-uploaded image.
Must be called after upload (FINALIZE) and before posts.create_tweet, OR auto-orchestrated via `CreateTweetParams.media_warnings`.

- `media_id` (str): The media_id_string from a prior upload
- `metadata` (MediaMetadata): Metadata config (see below)

Endpoint: `POST https://x.com/i/api/1.1/media/metadata/create.json`
No-op (no HTTP) when `metadata` is all defaults.

### Upload Types

#### UploadResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether upload succeeded |
| media_id | int \| None | Numeric media ID |
| media_id_string | str \| None | String media ID (use this to avoid precision issues) |
| error_msg | str | Error message |
| processing_info | ProcessingInfo \| None | Video processing info (only for video uploads) |

Methods:
- `is_video() -> bool` — Whether this is a video upload result

#### BatchUploadResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success_count | int | Successful uploads |
| failure_count | int | Failed uploads |
| media_ids | list[str] | List of media_id strings |
| results | list[UploadResult] | Per-upload results |

#### UploadOptions

| Attribute | Type | Description |
|-----------|------|-------------|
| duration_ms | float \| None | Video duration in milliseconds |
| processing_timeout | int \| None | Processing timeout in seconds |
| proxy_override | str \| None | Override proxy for this upload (None = no override) |
| use_proxy | bool | True = inherit client proxy (default); False = direct connection |

#### MediaMetadata

Image-level content warning / AI disclosure / Grok-edit-block.

| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| sensitive_warnings | list[str] | [] | Subset of "adult_content" (Nudity) / "graphic_violence" (Violence) / "other" (Sensitive) |
| self_reported_ai_generated | bool | False | Image-level "Generated with AI" tag (NOT the post-level Made with AI; see CreateTweetParams.ai_generated_disclosure) |
| block_grok_edit | bool | False | Prevent Grok from modifying this image |
| allow_download | bool \| None | None | None/True = Twitter default (allowed); False = explicitly disallow |

Constructor accepts all as keyword args. Pass `SensitiveCategory.NUDITY/VIOLENCE/SENSITIVE` constants OR raw strings.

#### SensitiveCategory

Validated string wrapper for sensitive_warnings entries.

| Class constant | String value | UI label |
|---|---|---|
| `SensitiveCategory.NUDITY` | "adult_content" | Nudity |
| `SensitiveCategory.VIOLENCE` | "graphic_violence" | Violence |
| `SensitiveCategory.SENSITIVE` | "other" | Sensitive |

Constructor `SensitiveCategory("xxx")` raises ValueError on invalid strings.

#### ProcessingInfo

| Attribute | Type | Description |
|-----------|------|-------------|
| state | ProcessingState | Processing state |
| check_after_secs | int \| None | Suggested wait time |
| progress_percent | int \| None | Processing progress percentage |
| error | ProcessingError \| None | Error info (only on failure) |

#### ProcessingState

| Attribute | Type | Description |
|-----------|------|-------------|
| state | str | State string: "pending", "in_progress", "succeeded", "failed" |

Methods:
- `is_succeeded() -> bool`
- `is_failed() -> bool`
- `is_pending() -> bool`

#### ProcessingError

| Attribute | Type | Description |
|-----------|------|-------------|
| code | int | Error code |
| name | str | Error name |
| message | str | Error message |

---

## Posts Module

### Access

```python
# Via Twitter client
result = await client.posts.create_tweet(text="Hello!")

# Or standalone
from x_api_rs.posts import PostsClient
posts_client = await PostsClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### create_tweet(text="", media_ids=None, reply_to_tweet_id=None, attachment_url=None, entity_id=None) -> TweetResult
Create a new tweet.

- `text` (str): Tweet text (max 280 chars for regular, 25000 for Blue)
- `media_ids` (list[str], optional): Media IDs from upload (max 4 images or 1 video)
- `reply_to_tweet_id` (str, optional): Tweet ID to reply to
- `attachment_url` (str, optional): Quote tweet URL
- `entity_id` (str, optional): Community ID (for community posts)

#### create_tweet_with_params(params) -> TweetResult
Create a tweet using a CreateTweetParams object.

- `params` (CreateTweetParams): Parameter object

#### delete_tweet(tweet_id) -> DeleteTweetResult
Delete a tweet.

- `tweet_id` (str): Tweet ID to delete

#### favorite_tweet(tweet_id) -> LikeResult
Like a tweet.

- `tweet_id` (str): Tweet ID to like

#### unfavorite_tweet(tweet_id) -> LikeResult
Unlike a tweet.

- `tweet_id` (str): Tweet ID to unlike

#### create_retweet(tweet_id) -> RetweetResult
Retweet a tweet.

- `tweet_id` (str): Tweet ID to retweet

#### delete_retweet(tweet_id) -> DeleteTweetResult
Undo a retweet.

- `tweet_id` (str): Original tweet ID (not the retweet ID)

#### get_tweets(user_id, cursor=None) -> GetTweetsResult
Get a user's tweet timeline with pagination.

- `user_id` (str): User ID
- `cursor` (str, optional): Pagination cursor

#### get_likes(user_id=None, cursor=None) -> GetLikesResult
Get a user's liked tweets. Defaults to current user if user_id is None.

- `user_id` (str, optional): User ID (defaults to current user)
- `cursor` (str, optional): Pagination cursor

#### clear_bookmarks() -> ClearBookmarksResult
Clear all bookmarks of the current account (one-shot, irreversible).
Equivalent to "Clear all bookmarks" on x.com.

### Posts Types

#### CreateTweetParams

| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| text | str | "" | Tweet text |
| media_ids | list[str] \| None | None | Media IDs |
| reply_to_tweet_id | str \| None | None | Reply target |
| attachment_url | str \| None | None | Quote tweet URL |
| entity_id | str \| None | None | Community ID |
| media_warnings | dict[str, MediaMetadata] \| None | None | Per-media content warning / AI disclosure. create_tweet auto-calls upload.set_media_metadata for each entry before posting (fail-fast). See MediaMetadata in Upload Types |
| ai_generated_disclosure | bool | False | Post-level "Made with AI" disclosure (injected into CreateTweet variables.content_disclosure). Independent from MediaMetadata.self_reported_ai_generated which is image-level |

#### TweetResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether creation succeeded |
| tweet_id | str \| None | Created tweet ID |
| tweet_info | TweetInfo \| None | Full tweet info (auto-filled on success) |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### DeleteTweetResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether deletion succeeded |
| tweet_id | str | Deleted tweet ID |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### LikeResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether like/unlike succeeded |
| tweet_id | str | Tweet ID |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### RetweetResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether retweet succeeded |
| source_tweet_id | str | Original tweet ID |
| retweet_id | str \| None | Retweet ID |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### TweetInfo

| Attribute | Type | Description |
|-----------|------|-------------|
| tweet_id | str | Tweet ID |
| text | str \| None | Tweet text |
| author_id | str \| None | Author user ID |
| author_screen_name | str \| None | Author username |
| created_at | str \| None | Creation timestamp |
| retweet_count | int \| None | Retweet count |
| favorite_count | int \| None | Like count |
| reply_count | int \| None | Reply count |
| quote_count | int \| None | Quote count |
| favorited | bool | Whether current user liked it |
| retweeted | bool | Whether current user retweeted it |
| media_urls | list[str] \| None | Media URLs |
| lang | str | Language code (e.g. "en", "zh") |
| possibly_sensitive | bool | Sensitive content flag |
| views_count | int \| None | View count (None for new tweets) |
| conversation_id_str | str | Conversation thread ID |
| is_quote_status | bool | Whether this is a quote tweet |
| quoted_status_id_str | str \| None | Quoted tweet ID (only for quotes) |
| author_display_name | str | Author display name |
| author_profile_image_url | str | Author avatar URL |
| author_is_blue_verified | bool | Author Blue verification status |
| author_followers_count | int | Author follower count |
| retweeted_status_id_str | str \| None | Original tweet ID if this is a retweet (use with delete_retweet) |
| in_reply_to_status_id_str | str \| None | Parent tweet ID if this is a reply/comment |
| in_reply_to_user_id_str | str \| None | Replied-to user ID |
| in_reply_to_screen_name | str \| None | Replied-to user screen name |

#### GetTweetsResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| tweets | list[TweetInfo] | Tweet list |
| next_cursor | str \| None | Next page cursor |
| has_more | bool | Whether there are more pages |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### GetLikesResult

Same structure as GetTweetsResult.

#### ClearBookmarksResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether clear succeeded |
| message | str | Server status message ("Done" on success) |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

---

## User Module

### Access

```python
# Via Twitter client
result = await client.user.get_profile("elonmusk")

# Or standalone
from x_api_rs.user import UserClient
user_client = await UserClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### get_profile(screen_name) -> UserResp
Get user profile by username (without @).

- `screen_name` (str): Twitter username

#### get_profile_by_id(rest_id) -> UserResp
Get user profile by REST ID.

- `rest_id` (str): User REST ID

#### get_about_account(screen_name) -> AboutAccountResult
Get account details including verification status.

- `screen_name` (str): Twitter username

#### edit_profile(params) -> EditUserResult
Update the authenticated user's profile.

- `params` (EditUserParams): Profile fields to update

#### change_profile_image(media_id) -> ChangeProfileImageResult
Change avatar using an uploaded image.

- `media_id` (str): Media ID from upload (use "banner_image" category)

#### change_background_image(media_id) -> ChangeBannerResult
Change banner using an uploaded image.

- `media_id` (str): Media ID from upload (use "banner_image" category)

#### get_followers(user_id, cursor=None) -> FollowListResult
Get a user's followers with pagination.

- `user_id` (str): Target user REST ID
- `cursor` (str, optional): Pagination cursor

#### get_following(user_id, cursor=None) -> FollowListResult
Get a user's following list with pagination.

- `user_id` (str): Target user REST ID
- `cursor` (str, optional): Pagination cursor

#### follow(user_id) -> FollowResult
Follow a user.

- `user_id` (str): User REST ID to follow

#### unfollow(user_id) -> UnfollowResult
Unfollow a user.

- `user_id` (str): User REST ID to unfollow

#### block(user_id) -> BlockResult
Block a user. Side effect: forces them to unfollow current account.
"Force-remove follower" pattern: `block(uid)` then `unblock(uid)`.

- `user_id` (str): User REST ID to block

#### unblock(user_id) -> UnblockResult
Remove block on a user.

- `user_id` (str): User REST ID to unblock

#### remove_profile_image() -> RemoveProfileImageResult
Restore default profile image (removes custom avatar).

#### remove_profile_banner() -> RemoveProfileBannerResult
Restore default profile banner.

#### get_user_lists(count=100) -> GetUserListsResult
Get Lists the current account has joined/subscribed to (and recommended ones).

- `count` (int, default 100): Maximum lists to return

#### unsubscribe_list(list_id) -> UnsubscribeListResult
Unsubscribe from a List.

- `list_id` (str): Numeric List ID

### User Types

#### UserResp

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| user_id | str \| None | User ID |
| screen_name | str \| None | Username (without @) |
| name | str \| None | Display name |
| description | str \| None | Bio |
| location | str \| None | Location |
| url | str \| None | Website URL |
| profile_image_url | str \| None | Avatar URL |
| background_image | str \| None | Banner URL |
| following_count | int \| None | Following count |
| followers_count | int \| None | Followers count |
| following | bool | Whether current user follows them |
| protected | bool \| None | Whether account is private |
| profile_interstitial_type | str \| None | Profile interstitial type |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### EditUserParams

| Attribute | Type | Description |
|-----------|------|-------------|
| name | str \| None | Display name |
| description | str \| None | Bio |
| location | str \| None | Location |
| url | str \| None | Website URL |

#### EditUserResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether update succeeded |
| name | str \| None | Updated display name |
| description | str \| None | Updated bio |
| location | str \| None | Updated location |
| url | str \| None | Updated URL |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### ChangeProfileImageResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether change succeeded |
| profile_image_url | str \| None | New avatar URL |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### ChangeBannerResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether change succeeded |
| banner_url | str \| None | New banner URL |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### AboutAccountResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| rest_id | str \| None | User REST ID |
| account_based_in | str \| None | Account location |
| location_accurate | bool \| None | Location accuracy |
| learn_more_url | str \| None | Learn more URL |
| source | str \| None | Information source |
| username_change_count | str \| None | Username change count |
| username_last_changed_at_msec | str \| None | Last username change timestamp |
| is_identity_verified | bool \| None | Identity verification status |
| is_blue_verified | bool \| None | Twitter Blue verification |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### FollowListResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| users | list[FollowUser] | User list |
| next_cursor | str \| None | Next page cursor |
| is_abnormal | bool | Abnormal flag (e.g., private account) |
| api_limit | ApiLimit \| None | Rate limit info |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

Methods:
- `has_more() -> bool` — Whether there are more pages

#### FollowUser

| Attribute | Type | Description |
|-----------|------|-------------|
| rest_id | str | User REST ID |
| name | str | Display name |
| screen_name | str | Username |
| location | str \| None | Location |
| can_dm | bool | Whether DM is allowed |
| followers_count | int | Followers count |
| following_count | int | Following count |
| media_count | int | Media count |
| protected | bool | Private account |
| profile_image_url | str \| None | Avatar URL |
| description | str \| None | Bio |

#### ApiLimit

| Attribute | Type | Description |
|-----------|------|-------------|
| limit | int \| None | Rate limit cap |
| remaining | int \| None | Remaining requests |
| reset | int \| None | Reset timestamp (Unix) |

#### FollowResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether follow succeeded |
| user_id | str | Target user ID |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### UnfollowResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether unfollow succeeded |
| user_id | str | Target user ID |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### BlockResult / UnblockResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| user_id | str | Target user ID |
| screen_name | str \| None | Target user screen name (filled on success) |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### RemoveProfileImageResult / RemoveProfileBannerResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### ListInfo

| Attribute | Type | Description |
|-----------|------|-------------|
| id_str | str | List ID (numeric string) |
| name | str | List name |
| description | str \| None | List description |
| mode | str | "Public" or "Private" |
| member_count | int | Member count |
| subscriber_count | int | Subscriber count |
| owner_id_str | str \| None | List owner user ID |
| owner_screen_name | str \| None | List owner username |
| owner_display_name | str \| None | List owner display name |
| following | bool | Whether current account subscribes |
| is_member | bool | Whether current account is a member |
| pinning | bool | Whether pinned by current account |
| created_at | int \| None | Creation timestamp (ms) |

#### GetUserListsResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| lists | list[ListInfo] | List of joined/subscribed Lists |
| next_cursor | str \| None | Next page cursor |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### UnsubscribeListResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether unsubscribe succeeded |
| list_id | str | Unsubscribed List ID |
| still_following | bool | Server response indicator (false on success) |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

---

## Inbox Module

### Access

```python
# Via Twitter client
result = await client.inbox.get_conversations()

# Or standalone
from x_api_rs.inbox import InboxClient
inbox_client = await InboxClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### get_conversations(cursor=None, conversation_limit=None) -> ConversationListResult
Get XChat conversation list (recommended over get_user_updates).

- `cursor` (InboxCursor, optional): Pagination cursor (None for first page)
- `conversation_limit` (int, optional): Conversations per page (default 20)

#### get_conversation_count() -> ConversationCountResult
Auto-paginate to count total conversations.

#### get_user_updates(active_conversation_id=None, cursor=None) -> UserUpdatesResult
**Deprecated.** Use `get_conversations()` instead.

- `active_conversation_id` (str, optional): Active conversation ID
- `cursor` (str, optional): Pagination cursor

### Inbox Types

#### ConversationListResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| error_msg | str | Error message |
| http_status | int | HTTP status code |
| has_message_requests | bool | Whether there are message requests |
| conversations | list[ConversationItem] | Conversation list |
| cursor | InboxCursor \| None | Next page cursor |
| count | int | Number of conversations in this page |

#### ConversationItem

| Attribute | Type | Description |
|-----------|------|-------------|
| conversation_id | str | Conversation ID |
| is_muted | bool | Whether muted |
| has_more | bool | Whether has more messages |
| participants | list[ConversationParticipant] | Participant list |
| latest_message | LatestMessage \| None | Latest message |
| read_events | list[ReadEvent] | Read events |

#### ConversationParticipant

| Attribute | Type | Description |
|-----------|------|-------------|
| rest_id | str | User REST ID |
| name | str | Display name |
| screen_name | str | Username |
| avatar_url | str \| None | Avatar URL |
| is_blue_verified | bool | Blue verified |
| verified | bool | Verified |
| protected | bool | Private account |
| suspended | bool | Suspended |
| can_dm | bool | Whether DM is allowed |
| created_at_ms | int \| None | Account creation timestamp |

#### LatestMessage

| Attribute | Type | Description |
|-----------|------|-------------|
| event_id | str \| None | Event ID |
| sender_id | str \| None | Sender user ID |
| conversation_id | str \| None | Conversation ID |
| timestamp | str \| None | Timestamp |
| text | str \| None | Message text |

#### ReadEvent

| Attribute | Type | Description |
|-----------|------|-------------|
| participant_id | str | Participant user ID |
| event_id | str \| None | Event ID |
| timestamp | str \| None | Timestamp |

#### InboxCursor

| Attribute | Type | Description |
|-----------|------|-------------|
| cursor_id | str | Cursor ID |
| graph_snapshot_id | str | Graph snapshot ID |

#### ConversationCountResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| error_msg | str | Error message |
| total_count | int | Total conversation count |
| pages_fetched | int | Number of pages fetched |

#### UserUpdatesResult (deprecated)

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| error_msg | str | Error message |
| http_status | int | HTTP status code |
| data_source | str \| None | Data source |
| cursor | str \| None | Pagination cursor |
| entries | list[InboxEntry] | Message entry list |
| users | dict[str, InboxUser] | User info mapping |

#### InboxEntry

| Attribute | Type | Description |
|-----------|------|-------------|
| message | InboxMessage \| None | Message (may be None for non-message events) |

#### InboxMessage

| Attribute | Type | Description |
|-----------|------|-------------|
| id | str | Message ID |
| time | str | Timestamp |
| conversation_id | str | Conversation ID |
| message_data | InboxMessageData | Message data |
| affects_sort | bool | Whether affects sort order |
| request_id | str \| None | Request ID |

#### InboxMessageData

| Attribute | Type | Description |
|-----------|------|-------------|
| id | str | Data ID |
| time | str | Timestamp |
| sender_id | str | Sender user ID |
| recipient_id | str | Recipient user ID |
| text | str | Message text |
| entities | MessageEntities | Message entities |

#### MessageEntities

| Attribute | Type | Description |
|-----------|------|-------------|
| urls | list[UrlEntity] | URL entities |
| hashtags | list[HashtagEntity] | Hashtag entities |
| user_mentions | list[UserMentionEntity] | User mention entities |

#### InboxUser

| Attribute | Type | Description |
|-----------|------|-------------|
| id_str | str | User ID (string) |
| name | str | Display name |
| screen_name | str | Username |
| profile_image_url | str \| None | Avatar URL |
| description | str \| None | Bio |
| followers_count | int | Followers count |
| friends_count | int | Following count |
| verified | bool | Verified |
| is_blue_verified | bool | Blue verified |
| protected | bool | Private account |

---

## Communities Module

### Access

```python
# Via Twitter client
result = await client.communities.search("python")

# Or standalone
from x_api_rs.communities import CommunitiesClient
comm_client = await CommunitiesClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### search(query, cursor=None) -> CommunitySearchResult
Search for communities.

- `query` (str): Search keyword (must not be empty)
- `cursor` (str, optional): Pagination cursor

#### join(community_id) -> JoinCommunityResult
Join a community.

- `community_id` (str): Community ID

### Communities Types

#### Community

| Attribute | Type | Description |
|-----------|------|-------------|
| rest_id | str | Community ID |
| name | str | Community name |
| icon | str | Community icon URL |
| member_count | int | Member count |

#### CommunitySearchResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| communities | list[Community] | Community list |
| next_cursor | str \| None | Next page cursor |
| error_msg | str | Error message |
| http_status | int | HTTP status code |
| has_more | bool | Whether there are more results (property) |

#### JoinCommunityResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether join succeeded |
| community_id | str | Community ID |
| already_member | bool | Whether already a member |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

---

## Settings Module

Account settings: query and modify account, search safety, audience, and Explore settings. 8 endpoint-level methods across 4 Twitter API endpoints. (v2.0.0 — breaking change from 1.0.x)

### Access

```python
# Via Twitter client
account = await client.settings.get_account_settings()

# Or standalone
from x_api_rs.settings import SettingsClient
settings_client = await SettingsClient.create(cookies, proxy_url=None, profile=None)
```

### Creation

```python
from x_api_rs.settings import SettingsClient

client = await SettingsClient.create(cookies, proxy_url=None, profile=None)
```

**Parameters:**
- `cookies` (str): Twitter account cookies string
- `proxy_url` (str, optional): Proxy server URL
- `profile` (ClientProfile, optional): Browser identity (TLS + HTTP). None = Chrome 136 Windows x64

### Methods

- `await client.get_account_settings() -> AccountSettings` — Query full account settings (18 fields, GET api.x.com/1.1/account/settings.json)
- `await client.update_account_settings(params: AccountSettingsUpdate) -> AccountSettings` — Batch update account settings (one POST, all fields optional via None; returns updated AccountSettings)
- `await client.get_search_safety() -> SearchSafety` — Query search safety: opt_in_filtering (hide sensitive) + opt_in_blocking (remove blocked/muted)
- `await client.set_search_safety(filtering: bool, blocking: bool) -> SettingResult` — Set both search safety fields in one POST (no pre-read required, both params mandatory)
- `await client.get_audience_settings() -> AudienceSettings` — Query video download protection setting (GraphQL AudienceAndTaggingQuery)
- `await client.set_protect_videos(enabled: bool) -> SettingResult` — Enable/disable video download protection (GraphQL AudienceAndTaggingAllowVideoDownloadsMutation; enabled=True means protection on / downloads blocked)
- `await client.get_explore_settings() -> ExploreSettings` — Query Explore personalization settings
- `await client.set_explore_settings(params: ExploreSettingsUpdate) -> SettingResult` — Update Explore settings (personalized_trends, current_location; all fields optional)

### AccountSettingsUpdate

All fields are optional (None = do not change). Pass only the fields you want to modify.

| Field | Type | Description |
|-------|------|-------------|
| protected | bool \| None | Posts protected (followers only) |
| nsfw_user | bool \| None | NSFW account flag |
| display_sensitive_media | bool \| None | Show others' sensitive media |
| geo_enabled | bool \| None | Location tagging on posts |
| allow_dms_from | str \| None | DM source: following/verified/all |
| allow_dm_groups_from | str \| None | DM group invite source: following/verified/all |
| allow_media_tagging | str \| None | Photo tagging: all/following/none |
| discoverable_by_email | bool \| None | Discoverable by email |
| discoverable_by_mobile_phone | bool \| None | Discoverable by phone |
| dm_receipt_setting | str \| None | DM read receipts: all_enabled/all_disabled |
| dm_quality_filter | str \| None | Low-quality DM filter: enabled/disabled |
| mention_filter | str \| None | Mention filter: unfiltered/following/verified |
| allow_ads_personalization | bool \| None | Ads personalization |
| allow_sharing_data_for_third_party_personalization | bool \| None | Third-party data sharing |

### ExploreSettingsUpdate

| Field | Type | Description |
|-------|------|-------------|
| use_personalized_trends | bool \| None | "Trends for you" personalization |
| use_current_location | bool \| None | Location-based Explore content |

### SettingResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether the setting was applied |
| setting_name | str | Name of the setting that was changed |
| error_msg | str | Error message if failed |

### AccountSettings

| Attribute | Type | Description |
|-----------|------|-------------|
| screen_name | str | Account screen name |
| language | str | Account language |
| country_code | str | Account country code |
| protected | bool | Whether account is protected |
| geo_enabled | bool | Whether geo is enabled |
| nsfw_user | bool | NSFW flag |
| nsfw_admin | bool | NSFW admin flag |
| display_sensitive_media | bool | Display sensitive media setting |
| allow_dms_from | str | DM permission setting (following/verified/all) |
| allow_dm_groups_from | str | DM group permission setting |
| allow_media_tagging | str | Media tagging permission (all/following/none) |
| discoverable_by_email | bool | Whether discoverable by email |
| discoverable_by_mobile_phone | bool | Whether discoverable by phone |
| dm_receipt_setting | str | DM read receipt state (all_enabled/all_disabled) |
| dm_quality_filter | str | Low-quality DM filter state (enabled/disabled) |
| mention_filter | str | Mention filter rule (unfiltered/following/verified) |
| allow_ads_personalization | bool | Whether ads personalization is allowed |
| allow_sharing_data_for_third_party_personalization | bool | Whether third-party data sharing is allowed |

### SearchSafety

| Attribute | Type | Description |
|-----------|------|-------------|
| opt_in_filtering | bool | Whether sensitive content is hidden in search |
| opt_in_blocking | bool | Whether blocked/muted accounts are removed from search results |

### AudienceSettings

| Attribute | Type | Description |
|-----------|------|-------------|
| protect_videos | bool | Whether video download protection is enabled |

### ExploreSettings

| Attribute | Type | Description |
|-----------|------|-------------|
| use_personalized_trends | bool | Whether "Trends for you" personalization is enabled |
| use_current_location | bool | Whether location-based content is enabled |

---

## Search Module

Search tweets, users, media, and Lists with cursor-based pagination. (Added in v1.0.7)

### Access

```python
# Via Twitter client
result = await client.search.search_top("python")

# Or standalone
from x_api_rs.search import SearchClient
search_client = await SearchClient.create(cookies, proxy_url=None, profile=None)
```

### Methods

#### search_top(query, cursor=None) -> SearchTweetsResult
Search tweets sorted by relevance/popularity.

- `query` (str): Search query (supports Twitter advanced search syntax)
- `cursor` (str, optional): Pagination cursor

#### search_latest(query, cursor=None) -> SearchTweetsResult
Search tweets sorted by time (newest first).

- `query` (str): Search query
- `cursor` (str, optional): Pagination cursor

#### search_people(query, cursor=None) -> SearchUsersResult
Search user accounts by keyword.

- `query` (str): Search query
- `cursor` (str, optional): Pagination cursor

#### search_media(query, cursor=None) -> SearchTweetsResult
Search tweets containing images or videos.

- `query` (str): Search query
- `cursor` (str, optional): Pagination cursor

#### search_lists(query, cursor=None) -> SearchListsResult
Search Twitter Lists.

- `query` (str): Search query
- `cursor` (str, optional): Pagination cursor

### Search Types

#### SearchTweetsResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| tweets | list[TweetInfo] | Tweet list |
| next_cursor | str \| None | Next page cursor |
| has_more | bool | Whether there are more pages |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### SearchUsersResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| users | list[UserResp] | User list (reuses User module's UserResp type) |
| next_cursor | str \| None | Next page cursor |
| has_more | bool | Whether there are more pages |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

#### SearchListsResult

| Attribute | Type | Description |
|-----------|------|-------------|
| success | bool | Whether request succeeded |
| lists | list[ListInfo] | Lists |
| next_cursor | str \| None | Next page cursor |
| has_more | bool | Whether there are more pages |
| error_msg | str | Error message |
| http_status | int | HTTP status code |

> **Note:** `SearchUsersResult.users` reuses `UserResp` from the User module. See [User Module](#user-module) for full UserResp field list (user_id, screen_name, name, description, followers_count, following_count, profile_image_url, background_image, location, url, following, protected, etc.)

#### ListInfo

| Attribute | Type | Description |
|-----------|------|-------------|
| list_id | str | List ID |
| name | str | List name |
| description | str \| None | List description |
| member_count | int | Member count |
| subscriber_count | int | Subscriber count |
| creator_name | str \| None | Creator display name |
| creator_screen_name | str \| None | Creator username |
| creator_id | str \| None | Creator user ID |
| banner_url | str \| None | Banner image URL |
| created_at | str \| None | Creation timestamp |

---

## XChat Module (E2E encrypted DMs)

XChat is X (Twitter)'s end-to-end encrypted DM protocol. Requires per-account
**enrollment** that generates ECDSA + ECDH P-256 keypairs and backs the
private keys to 3 Juicebox realms protected by a user PIN.

⚠️ **Encryption wire format is speculative**: cryptographic stack is fully
implemented (HKDF-SHA256, AES-GCM-256, ECDH P-256) but exact byte layout is
based on reverse-engineering of `xchat-kmp.5e42194a.js`, not on a real
double-enrolled capture. Send returns `warning="encryption_implemented_unverified"`
in that case. See `docs/api/xchat-enrollment.md` for the full caveat list.

### Access

```python
# Via Twitter client (method, not property — needs keystore_path)
client = await Twitter.create(cookies)
xchat = client.xchat("/path/to/xchat-keys.json")

# Or standalone
from x_api_rs.xchat import XChatClient
xchat = await XChatClient.create(cookies, "/path/to/xchat-keys.json")
```

### Enrollment

```python
result = await xchat.enroll("8527")  # 4-6 digit PIN recommended
# EnrollResult { user_id, key_version, registration_method, realms_registered }

await xchat.reset_pin("8527", "1234")
await xchat.delete_account("8527")    # irreversible, server + Juicebox + local
```

### Status

```python
status = await xchat.status()
# XChatStatus { enrolled, key_version, registration_method,
#               local_keystore_exists, key_version_synced }
if await xchat.is_enrolled():
    ...
```

### Send messages

```python
# Single
result = await xchat.send_message("1234567890", "hello e2e")
# XChatSendResult { success, message_id, event_id, sequence_id,
#                   timestamp_ms, warning, conversation_key_version }

# Batch — same text
batch = await xchat.send_batch(["111", "222", "333"], "broadcast")

# Batch — per-user text
batch = await xchat.send_batch_with_custom_texts(
    ["111", "222"], ["Hi Alice", "Hi Bob"]
)
# XChatBatchResult { results: list[XChatSendResult], total, success, failure }
```

### Receive messages

```python
page = await xchat.receive_messages(
    "984485658860208128:1973098228334960640",  # conversation_id = peer_id:self_id
    limit=20,
)
for msg in page.messages:
    print(msg.sender_id, msg.text, msg.signature_valid, msg.warning)
# ReceiveMessagesResult { messages: list[DecryptedMessage], has_more }
```

### XChatSendResult.warning values

| value | meaning |
|---|---|
| `None` | success without caveat |
| `"recipient_not_enrolled"` | peer not on XChat (has_keys=false) — sent plaintext + signature |
| `"recipient_disabled_xchat"` | peer is enrolled but does not currently accept XChat from this sender (bilateral social/privacy gate, confirmed via 2026-05-12 capture) — falls back to plaintext + signature |
| `"encryption_implemented_unverified"` | peer enrolled AND can_dm_on_xchat=true — AES-GCM-256 path used; wire format speculative (Twitter web does not currently expose encryption entry point, so wire details verified only at crypto.subtle level: IV=16B confirmed; HKDF/AAD/AddEncryptedConversationKeys still unverified) |
| `"send_failed: <reason>"` | batch single-item failure |

### DecryptedMessage fields

| field | type | description |
|---|---|---|
| event_id | str | server event id |
| message_id | str | client-generated id |
| sender_id | str | sender Twitter user_id |
| conversation_id | str | conversation id |
| timestamp_ms | int | server ms timestamp |
| sequence_id | int \| None | ordering id |
| text | str \| None | decrypted plaintext; None on decode/decrypt failure |
| encrypted | bool | whether encrypted path was used |
| signature_valid | bool \| None | Some(true/false) verified; None if skipped |
| warning | str \| None | see below |

### DecryptedMessage.warning values

| value | meaning |
|---|---|
| `"conversation_key_unknown"` | no local conv key — send a message first to trigger handshake |
| `"decrypt_failed"` | AES-GCM decryption failed |
| `"signature_input_unknown"` | inner_payload byte form unrecognized; verification skipped |
| `"sender_not_enrolled"` | cannot fetch sender public key — cannot verify |

### Error handling specifics

- `enroll` raises `RuntimeError` for: AlreadyEnrolled, PinIncorrect, JuiceboxError, EnrollRollbackFailed
- `send_*` raises `RuntimeError` only for fatal errors (auth, NotEnrolled). Per-message failures in batch become `warning="send_failed: ..."` items
- `receive_messages` **never raises** for per-message issues — all degradations become `warning` + `text=None` or `signature_valid=None`

### Keystore file

JSON file holding both PKCS#8 private keys + identity signature + key_version.
- `0600` permissions on Unix (auto)
- Atomic write (temp + rename)
- Treat as account credential — never commit, never log

### Known limitations (Phase 3.x)

- Encryption wire format unverified (≈17 `TODO(phase3.x-verify)` markers in source)
- Cross-device recovery (Juicebox `recover` flow) — Phase 3+
- Group conversations, media attachments, key rotation events — Phase 4

---

## Transaction Module

Generate X-Client-Transaction-Id headers for Twitter API requests.

### Access

```python
from x_api_rs.transaction import ClientTransaction

client_tx = await ClientTransaction.create(cookies=None, proxy_url=None)
tx_id = client_tx.generate_transaction_id("POST", "/1.1/dm/new2.json")
```

### Methods

#### ClientTransaction.create(cookies=None, proxy_url=None) -> ClientTransaction
Async creation. Fetches Twitter homepage to extract verification key.

- `cookies` (dict[str, str], optional): Cookies dict (e.g., {"auth_token": "xxx", "ct0": "yyy"})
- `proxy_url` (str, optional): Proxy URL

#### generate_transaction_id(method, path) -> str
Generate a transaction ID for an API request.

- `method` (str): HTTP method ("POST", "GET")
- `path` (str): Request path (e.g., "/1.1/dm/new2.json")

#### generate_transaction_id_with_time(method, path, time_now) -> str
Generate with a specific timestamp (for testing).

### Properties

- `key` (str): Verification key from Twitter homepage
- `animation_key` (str): Pre-computed animation key

---

## ClientProfile (Browser Identity)

`ClientProfile` binds the TLS fingerprint (JA3/JA4 + HTTP/2 SETTINGS) and HTTP headers
(User-Agent / Sec-Ch-Ua) into one self-consistent identity. Pass it to `Twitter.create(profile=...)`.

> **Why one parameter, not `enable_ja3` + `fingerprint`**: a real browser's TLS fingerprint and
> HTTP headers are the *same identity*. Splitting them lets you emit "Chrome 140 UA over a Chrome 136
> TLS handshake" — a UA/JA3 mismatch that anti-bot systems flag. `ClientProfile` keeps both layers
> consistent. HTTP/2 is always enabled; its SETTINGS fingerprint is emulated per profile.
>
> **Default (no profile) = Chrome 136 Windows x64** — blend into the largest real population, not
> stand out. Real per-account JA3 diversity requires *crossing browser families*; switching OS/version
> within Chrome does not change the JA3. Chrome is anchored at 136 (rquest-util's TLS upper bound and
> the only version whose TLS+HTTP stay verifiably consistent).

### Access

```python
from x_api_rs import Twitter, ClientProfile   # also: from x_api_rs.fingerprint import ClientProfile
```

### Per-Account Identity

```python
# Default — Chrome 136 Windows x64
client = await Twitter.create(cookies)

# Cross-family for real JA3 diversity
a = await Twitter.create(cookies_a, profile=ClientProfile.chrome("macos", "arm64"))
b = await Twitter.create(cookies_b, profile=ClientProfile.safari_ios("18.1.1"))
c = await Twitter.create(cookies_c, profile=ClientProfile.okhttp("5"))

# Disable TLS emulation (debug only)
d = await Twitter.create(cookies, profile=ClientProfile.none())

# Inspect the identity actually in use
info = a.fingerprint_info()   # ProfileInfo
print(info.browser, info.version, info.platform, info.user_agent)
```

### ClientProfile Factory Methods

All are static; invalid arguments raise `ValueError`.

#### ClientProfile.chrome(os: str, arch: str) -> ClientProfile
Chrome 136. `os`: `"windows"`|`"macos"`|`"linux"`; `arch`: `"x64"`|`"arm64"`.

#### ClientProfile.safari_mac(version: str) -> ClientProfile
Safari macOS: `"17.5"` | `"18"` | `"18.2"` | `"18.3"` | `"18.3.1"`.

#### ClientProfile.safari_ios(version: str) -> ClientProfile
Safari iOS (iPhone): `"17.4.1"` | `"18.1.1"`.

#### ClientProfile.safari_ipad() -> ClientProfile
Safari iPadOS 18.

#### ClientProfile.firefox(version: str) -> ClientProfile
Firefox: `"133"` | `"135"` | `"136"` | `"private136"` | `"android135"`.

#### ClientProfile.edge(version: str) -> ClientProfile
Edge: `"131"` | `"134"`.

#### ClientProfile.okhttp(version: str) -> ClientProfile
OkHttp (Android app): `"5"` | `"4.12"`.

#### ClientProfile.none() -> ClientProfile
Disable TLS emulation (debug).

#### ClientProfile.default() -> ClientProfile
Chrome 136 Windows x64 (same as passing no profile).

### ProfileInfo

Returned by `client.fingerprint_info()`.

| Attribute | Type | Description |
|-----------|------|-------------|
| browser | str | Browser family: chrome/safari/firefox/edge/okhttp/none |
| version | str | Version, e.g. "136" / "18.1.1" |
| platform | str | windows/macos/linux/ios/android/- |
| user_agent | str | Representative User-Agent string |

---

## Error Handling

All async methods raise `RuntimeError` on failure. Check `result.success` for operation status.

```python
try:
    result = await client.dm.send_message("123", "Hello")
    if result.success:
        print(f"Sent! Event ID: {result.event_id}")
    else:
        print(f"Failed: {result.error_msg} (HTTP {result.http_status})")
except RuntimeError as e:
    print(f"Error: {e}")
```

## Common Patterns

### Batch DM with media

```python
# Upload same image multiple times (each DM needs a unique media_id)
batch_upload = await client.upload.image_multiple_times(image_bytes, "dm_image", 3)
user_ids = ["user1", "user2", "user3"]
media_ids = [mid if mid else None for mid in batch_upload.media_ids]
result = await client.dm.send_batch(user_ids, "Check this out!", media_ids=media_ids)
```

### Pagination

```python
# Get all followers
cursor = None
all_followers = []
while True:
    result = await client.user.get_followers("44196397", cursor)
    all_followers.extend(result.users)
    if not result.has_more():
        break
    cursor = result.next_cursor
```

### Conversation listing

```python
# Get first page of conversations
result = await client.inbox.get_conversations()
for conv in result.conversations:
    for p in conv.participants:
        print(f"  @{p.screen_name}")
    if conv.latest_message:
        print(f"  Last: {conv.latest_message.text}")

# Get next page
if result.cursor:
    page2 = await client.inbox.get_conversations(cursor=result.cursor)
```

---

## Raw HTTP Module (Debug Interface)

Direct HTTP access for debugging any X API endpoint. Reuses authentication (Bearer Token, CSRF, Cookies), JA3/H2 fingerprint emulation, and ClientTransaction injection. Intended for debugging and exploration — production code should use typed module methods.

### Access

```python
# Via Twitter main entry (recommended)
response = await client.raw.raw_get("https://api.x.com/1.1/account/settings.json")
```

### Methods

- `await client.raw.raw_get(url: str, params: list[tuple[str, str]] | None = None) -> RawResponse` — Send authenticated GET request to any X API endpoint. `params` are appended to the URL query string.
- `await client.raw.raw_post(url: str, params: list[tuple[str, str]] | None = None, json_str: str | None = None, form: list[tuple[str, str]] | None = None) -> RawResponse` — Send authenticated POST request. `json_str` (must be pre-serialized via `json.dumps()`) and `form` are mutually exclusive; `json_str` takes priority when both are provided.

### RawResponse

| Attribute | Type | Description |
|-----------|------|-------------|
| `status` | `int` | HTTP status code (e.g. 200, 401, 403) |
| `body` | `str` | Raw response body text |
| `headers` | `dict[str, str]` | Response headers (lowercase keys) |

### Examples

```python
import json

# GET request with query params
response = await client.raw.raw_get(
    "https://api.x.com/1.1/account/settings.json",
    params=[("include_nsfw_user_flag", "true")]
)
data = json.loads(response.body)
print(data["screen_name"])

# POST with JSON body
payload = {"optInFiltering": True, "optInBlocking": False}
user_id = "44196397"
response = await client.raw.raw_post(
    f"https://x.com/i/api/1.1/strato/column/User/{user_id}/search/searchSafety",
    json_str=json.dumps(payload)
)
print(response.status)  # 200

# POST with form body
response = await client.raw.raw_post(
    "https://api.x.com/1.1/account/settings.json",
    form=[("nsfw_user", "true"), ("display_sensitive_media", "true")]
)
print(response.status)  # 200
```
