XClient
A comprehensive, production-grade Elixir client for the X (Twitter) API v1.1.
Features
- ✅ Full API v1.1 Coverage — tweets, media, users, friendships, favorites, DMs, lists, search, account, trends, geo, help, and application endpoints
-
✅ OAuth 1.0a Authentication — HMAC-SHA1 request signing via
oauther, using the correct%OAuther.Credentials{}struct -
✅ ETS-backed Rate Limiting — non-blocking pre-request checks with
read_concurrency: true; writes go through the GenServer, reads hit ETS directly - ✅ Exponential Backoff Retry — automatic retry on 429 responses with configurable base delay and max attempts
- ✅ Chunked Media Uploads — full INIT / APPEND / FINALIZE flow for videos up to 512 MB
- ✅ Telemetry — structured events on every request and rate-limit event
-
✅ Typed Client Struct —
%XClient.Client{}with@enforce_keys, replacing the original raw-map pattern -
✅ Zero Compiler Warnings — clean
mix compile,mix credo --strict, andmix dialyzer -
✅ Shared Param Builder —
XClient.Paramseliminates thebuild_params/1duplication that existed across 10+ modules in the original codebase
Installation
Add x_client to your list of dependencies in mix.exs:
def deps do
[
{:x_client, "~> 1.1"}
]
endThen run:
mix deps.getConfiguration
Via config.exs
# config/config.exs
config :x_client,
consumer_key: "YOUR_CONSUMER_KEY",
consumer_secret: "YOUR_CONSUMER_SECRET",
access_token: "YOUR_ACCESS_TOKEN",
access_token_secret: "YOUR_ACCESS_TOKEN_SECRET"Via environment variables (recommended for production)
# config/runtime.exs
config :x_client,
consumer_key: {:system, "X_CONSUMER_KEY"},
consumer_secret: {:system, "X_CONSUMER_SECRET"},
access_token: {:system, "X_ACCESS_TOKEN"},
access_token_secret: {:system, "X_ACCESS_TOKEN_SECRET"}Optional tuning
config :x_client,
base_url: "https://api.x.com/1.1", # default
upload_url: "https://upload.x.com/1.1", # default
auto_retry: true, # default
max_retries: 3, # default
retry_base_delay_ms: 1_000, # default — doubles each retry
request_timeout_ms: 30_000 # defaultQuick Start
# Post a tweet
{:ok, tweet} = XClient.Tweets.update("Hello from Elixir! 🚀")
# Upload an image and attach it to a tweet
{:ok, media} = XClient.Media.upload("priv/photo.jpg")
{:ok, tweet} = XClient.Tweets.update(
"Check this out!",
media_ids: [media["media_id_string"]]
)
# Search tweets
{:ok, %{"statuses" => tweets}} = XClient.Search.tweets("elixir lang", count: 100)
# Get a user's timeline
{:ok, tweets} = XClient.Tweets.user_timeline(screen_name: "elixirlang", count: 50)
# Follow a user
{:ok, user} = XClient.Friendships.create(screen_name: "elixirlang")
# Like a tweet
{:ok, tweet} = XClient.Favorites.create("123456789")
# Send a Direct Message
{:ok, event} = XClient.DirectMessages.send("987654321", "Hello!")
# Verify credentials
{:ok, account} = XClient.verify_credentials()Multi-account Usage
Create a per-request client instead of relying on global config:
client = XClient.client(
consumer_key: "CK",
consumer_secret: "CS",
access_token: "AT",
access_token_secret: "ATS"
)
{:ok, tweet} = XClient.Tweets.update(client, "Tweet from account 2!")
{:ok, user} = XClient.Users.show([screen_name: "elixirlang"], client)Error Handling
Every function returns {:ok, term()} or {:error, %XClient.Error{}}:
case XClient.Tweets.update("Hello!") do
{:ok, tweet} ->
IO.puts("Posted: #{tweet["id_string"]}")
{:error, %XClient.Error{status: 429, rate_limit_info: info}} ->
IO.puts("Rate limited. Resets at #{info[:reset]}")
{:error, %XClient.Error{status: 401, code: 32}} ->
IO.puts("Authentication failed")
{:error, %XClient.Error{status: 403, code: 187}} ->
IO.puts("Duplicate tweet")
{:error, %XClient.Error{message: message}} ->
IO.puts("Error: #{message}")
endAvailable Modules
| Module | Endpoints |
|---|---|
XClient.Tweets | update, destroy, retweet, unretweet, show, lookup, user_timeline, mentions_timeline, retweets_of_me, retweets, retweeters_ids |
XClient.Media | upload, chunked_upload, upload_status, add_metadata |
XClient.Users | show, lookup, search, suggestions, suggestions_slug, suggestions_members |
XClient.Friendships | create, destroy, show, followers_ids, followers_list, friends_ids, friends_list |
XClient.Favorites | create, destroy, list |
XClient.DirectMessages | send, destroy, list, show |
XClient.Lists | list, statuses, show, members, members_show, memberships, ownerships, subscribers, subscribers_show, subscriptions |
XClient.Search | tweets |
XClient.Account | verify_credentials, settings, update_settings, update_profile, update_profile_image, update_profile_banner, remove_profile_banner |
XClient.Trends | place, available, closest |
XClient.Geo | id |
XClient.Help | configuration, languages, privacy, tos |
XClient.API | rate_limit_status |
Media Uploads
Simple image upload (< 5 MB)
# From a file path (MIME type auto-detected)
{:ok, media} = XClient.Media.upload("priv/photo.jpg")
# From binary data (media_type required)
data = File.read!("priv/photo.png")
{:ok, media} = XClient.Media.upload(data, media_type: "image/png")
# With alt text for accessibility
{:ok, media} = XClient.Media.upload("priv/photo.jpg",
alt_text: "A sunset over the ocean")Video upload (chunked, up to 512 MB)
{:ok, media} = XClient.Media.upload("priv/clip.mp4",
media_category: "tweet_video")
{:ok, tweet} = XClient.Tweets.update(
"My new video!",
media_ids: [media["media_id_string"]]
)Multiple images (up to 4)
media_ids =
["img1.jpg", "img2.jpg", "img3.jpg", "img4.jpg"]
|> Enum.map(fn path ->
{:ok, media} = XClient.Media.upload(path)
media["media_id_string"]
end)
{:ok, tweet} = XClient.Tweets.update("Four photos!", media_ids: media_ids)Pagination
Cursor-based pagination is supported on all collection endpoints:
# First page
{:ok, %{"ids" => ids, "next_cursor" => cursor}} =
XClient.Friendships.followers_ids(screen_name: "elixirlang")
# Next page
{:ok, %{"ids" => more_ids, "next_cursor" => next_cursor}} =
XClient.Friendships.followers_ids(screen_name: "elixirlang", cursor: cursor)Rate Limiting
The library tracks rate limit windows from response headers and blocks requests proactively when a window is exhausted:
# Check stored rate limit info for an endpoint
info = XClient.RateLimiter.get_limit_info("statuses/user_timeline.json")
# => %{limit: 900, remaining: 847, reset: 1712345678}
# Check all rate limits from the API
{:ok, limits} = XClient.API.rate_limit_status()
{:ok, limits} = XClient.API.rate_limit_status(resources: "statuses,friends")Telemetry
Attach to XClient's telemetry events for observability:
# In your application.ex start/2 or a dedicated telemetry supervisor
:telemetry.attach_many(
"my-app-x-client",
[
[:x_client, :request, :start],
[:x_client, :request, :stop],
[:x_client, :request, :error],
[:x_client, :rate_limit, :blocked]
],
&MyApp.Telemetry.handle_event/4,
nil
)
# Event shapes:
# [:x_client, :request, :start] — %{}, %{method: atom, url: binary, endpoint: binary}
# [:x_client, :request, :stop] — %{duration_us: integer}, %{status: integer, ...}
# [:x_client, :request, :error] — %{duration_us: integer}, %{reason: term}
# [:x_client, :rate_limit, :blocked] — %{}, %{endpoint: binary}Development
# Install dependencies
mix deps.get
# Run the full check suite
mix check # format + credo + dialyzer
# Run tests
mix test
mix test.ci # with coverage
# Generate documentation
mix docs
# Individual checks
mix format --check-formatted
mix credo --strict
mix dialyzerContributing
- Fork the repository
-
Create your feature branch (
git checkout -b feature/my-feature) -
Ensure all checks pass:
mix check - Submit a Pull Request
License
This project is licensed under the MIT License — see LICENSE for details.