Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

X (x)

X (formerly Twitter) is the only provider in this set that authenticates with OAuth 1.0a instead of OAuth 2.0. Every request — the OAuth handshake, media upload and post creation — is signed with HMAC-SHA1 using the app’s consumer key/secret and the user’s access token/secret. The API calls themselves are X API v2 endpoints (/2/...), with the v2 chunked media upload flow for images and video.

  • Rust source: crates/rustiz-providers/src/x.rs
  • Upstream port: x.provider.ts (upstream uses twitter-api-v2 + sharp)
  • Editor: HTML (articles keep rich text; regular posts are flattened to text)
  • Connection mode: oauth (OAuth 1.0a, a human opens a login URL)
  • Related provider: none

Overview

PropertyValue
Identifierx
Display nameX
Connection modeoauth (OAuth 1.0a 3-legged flow)
Editorhtml
Channel kindsocial
Comments / threadsYes. supports_comments = true; each extra entry is posted as a reply with reply.in_reply_to_tweet_id
Pending / async publishingYes. supports_pending = true; media is uploaded and polled, then the post is created behind an arm → confirm → publish handshake
AnalyticsYes. supports_analytics = true; may be disabled with DISABLE_X_ANALYTICS
Post analyticsYes. supports_post_analytics = true
MentionsYes. supports_mentions = true; GET /2/users/by/username/{handle}
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10
Character limit (max_length)280 normally, 4,000 when the stored Verified additional setting is true, 100,000 for post_type = "article"
API basehttps://api.x.com (X_API_BASE_URL)
Tool tip“You will be logged in into your current account, if you would like a different account, change it first on X”

Important: the Rust port uses OAuth 1.0a because that single credential can call both v2 post endpoints and the v2 media endpoints. Upstream Postiz also uses OAuth 1.0a; its media path historically needed the legacy v1.1 upload endpoint, while Rustiz uses the newer v2 chunked upload.

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["X_API_KEY", "X_API_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
X_API_KEYYes—Consumer key (“API Key”) from the X app’s Keys and tokens tab. Signs every OAuth 1.0a request.
X_API_SECRETYes—Consumer secret (“API Key Secret”) from the same tab. Used as the HMAC-SHA1 signing key together with the token secret.
X_API_BASE_URLNohttps://api.x.comOrigin for the API and the OAuth 1.0a legs. Read via base_url("X_API", ...); trailing slash stripped. This is the real API override.
X_URLNoFRONTEND_URLOnly overrides the base of the OAuth callback ({X_URL}/integrations/social/x). It does not change the API base in the Rust port, despite what the Postiz docs say.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the callback URI when X_URL is unset. Trailing slashes are stripped.
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state (code_verifier, state) at rest.
DISABLE_X_ANALYTICSNounsetWhen true, analytics() and post_analytics() return an empty list without calling X.
STRIP_LINKS_FROM_X_POSTSNounsetWhen true, URLs and bare domains are removed from post and comment text before publishing.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Article inline images and the article cover are rejected unless their URL contains this domain.

rustiz providers --status only checks that X_API_KEY and X_API_SECRET are non-empty. It cannot tell whether the keys belong to the app whose callback URI is registered, or whether the app has write permission.

Step-by-Step Developer App Setup

1. Create the developer account and app

  1. Sign in at https://console.x.com (the old developer.twitter.com portal redirects here). Accept the Developer Agreement and complete the profile.
  2. Create a Project and an App. The app gets an API Key and API Key Secret (the consumer credentials Rustiz needs).
  3. Open the app’s User authentication settings:
    • Enable OAuth 1.0a.
    • Set App permissions to Read and Write. Read-only tokens fail at POST /2/tweets; the OAuth handshake itself still succeeds.
    • Add the exact Callback URI (see step 3).
    • A website URL is required by the form; any valid HTTPS URL works.
  4. Open Keys and tokens and copy API Key and API Key Secret. The access token/secret shown there belong to the app owner and are not used by Rustiz; each channel gets its own token through the 3-legged flow.

2. Legacy portal note: app type

Older X portals asked for a Type of App. Postiz’s guide warns that choosing Web App, Automated App or Bot makes OAuth 1.0a fail with error code 32 and that Native App must be selected. If your console still shows that selector, choose Native App. Current consoles replaced it with the OAuth 1.0a toggle and callback URI fields.

3. Redirect URI (exact)

X::callback_url() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/x", base.trim_end_matches('/'))
}

where base is X_URL when set, otherwise Config::frontend_url(). Examples:

https://social.example.com/integrations/social/x   # production
http://localhost:5000/integrations/social/x        # Docker / rustiz serve default
http://localhost:4200/integrations/social/x        # upstream Postiz dev UI

Register the value exactly as above. X_URL is unusual: setting it to an X-compatible API origin (as the Postiz documentation suggests) changes the callback URI, not the API origin. To point Rustiz at a compatible API, set X_API_BASE_URL instead.

X accepts http://localhost callback URIs for development. There is no redirectmeto.com wrapper in this provider; if X rejects a plain-HTTP local callback, use an HTTPS tunnel and set X_URL to that public origin.

4. OAuth 1.0a handshake performed by Rustiz

  1. POST {api}/oauth/request_token with oauth_callback={callback} and x_auth_access_type=write, signed with the consumer key/secret.

  2. The temporary token/secret pair is packed into code_verifier as "{oauth_token}:{oauth_token_secret}"; state is the temporary token.

  3. The human is sent to:

    {api}/oauth/authenticate?oauth_token={token}&force_login=false
    
  4. On return, POST {api}/oauth/access_token with oauth_token and oauth_verifier, signed with the temporary token secret.

  5. GET /2/users/me?user.fields=username,verified,verified_type,profile_image_url,name verifies the token and stores the profile. The final stored token is "{access_token}:{access_token_secret}".

There are no scopes in OAuth 1.0a and check_scopes() is never called. The effective permission is the app-level Read and Write setting. An app set to Read-only connects successfully and fails only when posting.

5. Free vs paid access (2026)

X retired the old self-serve tiers. As of 2026:

PlanStatusPosting allowance / cost
Legacy FreeClosed to new developers on 6 Feb 2026Was 500 posts/month, 100 reads/month; active users were moved to pay-per-use with a $10 voucher
Legacy BasicRetired for new signups (migrated from June 2026)Was $200/month, 3,000 posts/month per user, 15,000 reads/month
Legacy ProRetired for new signups (migrated from September 2026)Was $5,000/month, 300,000 posts/month, 1,000,000 reads/month
Pay-per-use (current)Default for new developersPrepaid credits: $0.015 per post created, $0.20 if the post contains a URL, $0.005 per post read, capped at 3M post reads/month
Public UtilityApplication onlyFree scaled access for approved public-interest apps

Budget for link posts: the URL surcharge is more than ten times a plain post. Media upload calls themselves are not the expensive part; post creation is.

6. Sandbox / testing vs production

X has no sandbox environment and no app-review step comparable to Meta. The app is live as soon as it has credentials and credits:

  • Use a dedicated test X account and a separate app/project for development.
  • A post created through the API is immediately public. There is no “development mode” that hides it.
  • Rate limits apply even in testing. POST /2/tweets is limited to 100 requests per 15 minutes per user and 10,000 per 24 hours per app.
  • If you run out of credits, posting fails; set a budget/alert in the Developer Console.
  • X_URL/X_API_BASE_URL can point at a mock server for tests, but the OAuth 1.0a signature must still match the real consumer credentials.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect x
rustiz channels connect x --open
rustiz channels connect x --timeout 600
  • The login URL is printed to stderr (login url: ...); --open launches the browser.
  • With a local FRONTEND_URL (http://localhost:… / http://127.0.0.1:…) the CLI serves the callback on the frontend port itself and waits up to --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the running server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.
  • --external-url is meaningless for X and should not be passed.
  • --fields is for credential providers; X needs no fields.

Behind the scenes the CLI calls POST /public/v1/integrations/x/connect, which reaches connect::start() → oauth::begin() → X::generate_auth_url().

MCP connectChannelTool

{
  "provider": "x"
}

The tool returns:

{
  "connected": false,
  "pending": true,
  "connectionId": "…",
  "url": "https://api.x.com/oauth/authenticate?oauth_token=…&force_login=false",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Then poll:

{ "connectionId": "…" }

connectChannelTool with an MCP client that advertises URL elicitation sends the login URL through elicitation/create instead of returning it as text.

integrationSchema reports:

{
  "connection": {
    "type": "oauth",
    "headless": false,
    "requiresHuman": true,
    "requiresExternalUrl": false
  }
}

There is no follow-up triggerTool / channelSetupTool call: is_between_steps is false and the channel is stored directly after the callback.

What not to send

  • customFields is for credential providers. In connect.rs, any customFields object routes to connect_custom(), which base64-encodes it as the OAuth code; X then treats it as an authorization code and fails. Never send customFields to x.
  • externalUrl is ignored by X; do not pass it.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "post_type": {
      "type": "string",
      "enum": ["post", "article"],
      "description": "post = a regular short post (plain text). article = a long-form X article: write the post content as HTML (p, h1, h2, h3, ul, ol, li, strong, u, a) - the formatting is kept in the article"
    },
    "article_title": {
      "type": "string",
      "minLength": 1,
      "description": "The title of the article, required when post_type is article"
    },
    "article_status": {
      "type": "string",
      "enum": ["draft", "published"],
      "description": "draft = save the article as a draft on X, published = publish it right away, required when post_type is article"
    },
    "article_cover": {
      "type": "object",
      "description": "Optional cover image for the article, only used when post_type is article",
      "properties": {
        "id": { "type": "string", "description": "Media id of the uploaded picture" },
        "path": { "type": "string", "description": "Public URL of the uploaded picture" },
        "alt": { "type": "string", "description": "Alt text of the cover" },
        "thumbnail": { "type": "string", "description": "Thumbnail URL" }
      },
      "required": ["id", "path"]
    },
    "who_can_reply_post": {
      "type": "string",
      "enum": ["everyone", "following", "mentionedUsers", "subscribers", "verified"],
      "description": "Who can reply to the post; required for regular posts, ignored for articles"
    },
    "community": {
      "type": "string",
      "pattern": "^(https://x\\.com/i/communities/\\d+)?$",
      "description": "Optional X community URL, for example https://x.com/i/communities/1493446837214187523"
    },
    "made_with_ai": {
      "type": "boolean",
      "description": "Mark the post as made with AI"
    },
    "paid_partnership": {
      "type": "boolean",
      "description": "Mark the post as a paid partnership"
    }
  },
  "additionalProperties": false
}
FieldTypeRequired in practiceBehaviour
post_type"post" / "article"No; defaults to postarticle switches to the Articles endpoints and HTML content
article_titlestring, non-emptyYes when post_type=articleSent as the draft title
article_status"draft" / "published"Yes when post_type=articledraft stops after POST /2/articles/draft; published calls the publish endpoint
article_coverobjectNo{id, path, alt?, thumbnail?}; uploaded separately as a JPEG tweet_image
who_can_reply_postenumYes for regular postsOmitted from the request when everyone; sent as reply_settings otherwise
communityX community URLNo; empty string allowedAdds share_with_followers: true and community_id (the last path segment)
made_with_aibooleanNo; defaults falseAlways sent to POST /2/tweets (and to comments)
paid_partnershipbooleanNo; defaults falseAlways sent to POST /2/tweets (and to comments)

additionalProperties: false is advisory: the exported schema tells UI/MCP clients not to send unknown keys, but the Rust validate_settings() only checks the fields listed above and ignores extra keys.

validate_settings()

  • settings must be a JSON object.
  • post_type missing is treated as "post"; any other value is an error.
  • community, when non-null, must match ^https://x\.com/i/communities/\d+$. An empty string matches because the whole group is optional.
  • For post_type != "article", who_can_reply_post must be exactly one of everyone, following, mentionedUsers, subscribers, verified. A missing value is an error even though the JSON schema does not mark it required.
  • For article: article_title must be a non-empty string and article_status must be draft or published.
  • article_cover, when non-null:
    • must be an object;
    • id must be a string (an empty string is accepted — it is typed as a string by the DTO);
    • path must be non-empty and end in .png, .jpg, .jpeg, .gif, .webp or .mp4;
    • if RESTRICT_UPLOAD_DOMAINS is set, path must contain that domain;
    • alt must be a string when present;
    • thumbnail, when a non-empty string, must parse as http/https.
  • made_with_ai and paid_partnership must be boolean or string when present (the validator accepts numeric-looking strings; as_bool then reads "true" case-insensitively).

check_validity()

Only articles are pre-checked:

  1. If post_type != "article" → no checks.
  2. If any media path contains .mp4 → “X articles only support images”.
  3. If article_status != "published" and posts.len() > 1 → “A draft article cannot have thread replies, remove them or publish the article”.

A draft article has no public post id to reply to, so the thread check is deliberate.

Media requirements and upload behaviour

MediaRule in the Rust port
ImageAny path not containing .mp4. Resized to width 1000 px with Lanczos3 and re-encoded. Uploaded as tweet_image (non-article) or as JPEG tweet_image (article)
Animated GIFPath contains .gif; every frame is resized and re-encoded, uploaded as tweet_gif
VideoOnly paths containing .mp4 are treated as video. .mov is treated as an image and fails at decode. Uploaded as tweet_video
Article inline images<img src> with an http(s) scheme (case-insensitive) and allowed by RESTRICT_UPLOAD_DOMAINS; uploaded as JPEG and inserted as Draft.js atomic blocks
Article coverUploaded as JPEG tweet_image and sent as cover_media
Alt textIgnored for X. The media metadata endpoint (POST /2/media/metadata) is never called

Platform limits enforced by X, not by Rustiz:

  • Up to 4 photos, 1 GIF, or 1 video per post.
  • Image ≤ 5 MB, GIF ≤ 15 MB; video duration 20 minutes / 8 GB by default, 125 minutes / 16 GB for Premium accounts. The cap follows the posting account’s Premium status, not the API plan.
  • Chunked upload: initialize → append → finalize → STATUS. Rustiz uses 1 MiB segments (the API allows up to 5 MB, server max 8 MB). Segments are numbered from 0 and sent as multipart media with Content-Type: application/octet-stream.
  • finalize may return processing_info; Rustiz polls GET /2/media/upload?command=STATUS&media_id=….

Articles

Articles are a v2 feature added to X in June 2026:

  1. POST /2/articles/draft with {title, content_state, cover_media?}. content_state is Draft.js: blocks plus entities. Rustiz converts the HTML subset p, h1, h2, h3, ul, ol, li, strong, u, a, img into blocks:
    • h1 → header-one, h2/h3 → header-two (header-three is downgraded because X’s draft endpoint returned 503 for it);
    • ul/ol → unordered-list-item / ordered-list-item;
    • strong → bold inline style; a → link entity;
    • img → atomic block with an image entity.
  2. If article_status = "draft", the returned article id is stored and the release URL is https://x.com/i/articles.
  3. If article_status = "published", POST /2/articles/{id}/publish is called and data.post_id becomes the published post id. The release URL uses https://twitter.com/{profile}/status/{post_id}.

Articles require an X Premium subscription on the posting account. X returns a “not authorized to create or publish articles” error which Rustiz maps to that message.

Plugs

MethodKindFieldsEffect
autoRepostPost@Plug (run_plug)likesAmountReads GET /2/tweets/{id}/liking_users; when result_count >= likesAmount, waits 2 s and reposts via POST /2/users/{internal_id}/retweets
autoPlugPost@Plug (run_plug)likesAmount, postWhen the like threshold is met, replies to the post with the stripped text
repostPostUsers@PostPlug (run_post_plug)—Reads /2/users/me for the target integration and reposts; errors are swallowed

likesAmount is read as a string or number (plug_text), then parsed as i64; a missing/invalid value becomes 0, so the plug fires on the first check.

Implementation Notes & Gotchas

  • OAuth 1.0a, not OAuth 2.0. Do not configure OAuth 2.0 client credentials and expect them to work: X_API_KEY/X_API_SECRET are the consumer key and secret, and the stored token is access_token:access_token_secret. The provider’s tests verify the signature against X’s documented worked example (Ls93hJiZbQ3akF3HF3x1Bz8/zU4=).
  • X_URL is the callback origin, not the API origin. The Postiz docs say X_URL overrides the API base. In x.rs, callback_url() reads X_URL, while api() reads X_API_BASE_URL. Setting X_URL to a self-hosted X-compatible API therefore breaks the callback instead of redirecting API traffic. Use X_URL only to point the OAuth callback at an HTTPS tunnel when FRONTEND_URL is a local address.
  • No token refresh. OAuth 1.0a tokens are long-lived; authenticate() stores expires_in = 999_999_999 (about 31.7 years) and an empty refresh_token. refresh_token() returns an empty access token with expires_in: 0, so the engine marks the channel refreshNeeded and asks the user to reconnect if X ever rejects the token. There is no automatic repair.
  • Token secrets are stored in one string. access_token is "{token}:{secret}"; anything that splits on : assumes exactly one colon. The token itself is URL-safe, so this is stable, but do not add prefixes.
  • Arm → confirm → publish. post_pending() uploads media and stores {message, settings, mediaIds, coverMediaId, inlineMediaIds, processingIds, attempting, confirmed}. check_post_status() performs one media STATUS read per processing id. finalize_post() first flips attempting=true, confirmed=false and returns Pending; the next check witnesses it and sets confirmed=true; only then is POST /2/tweets sent. If a run dies after the confirm but before the result is recorded, the next check refuses to retry with “X may have already published this post” rather than risk a duplicate.
  • Known duplicate risk window. The handshake prevents re-creating a post when a previous attempt was confirmed but its result was lost. It cannot detect a create that X accepted and whose HTTP response was lost before confirmed was persisted; in that narrow window a retry can duplicate.
  • Comment processing waits longer than post processing. post() has an 8-minute overall budget and sleeps 20 s between checks. comment() waits for each processing media with default_wait_ms = 1000 and a 7-minute max.
  • Article image waits have no timeout. upload_bytes() polls processing_info with max_wait_ms = None. If X keeps returning a check_after_secs value and never reaches succeeded/failed, that call can loop indefinitely. Video uploads avoid this by returning still_processing to the durable workflow instead of waiting inside upload_video().
  • Media is uploaded sequentially. upload_media_entries() loops posts and media one by one on purpose, so a large carousel does not hold every file in memory at once. With max_concurrent_job = 10, a media-heavy queue can be slower than the concurrency number suggests.
  • Image re-encoding quirk. resize_gif() always returns GIF bytes, even for JPEG/PNG/WebP inputs (it encodes a single frame). upload_image() then declares the original MIME type (image/jpeg, image/png, …). If X rejects an otherwise valid image with an unsupported-media error, this mismatch is the first thing to inspect. Article images take the separate JPEG path and are unaffected.
  • Article cover accepts .mp4 but cannot process it. validate_settings() allows .mp4 in article_cover.path, yet the upload path always calls resize_jpeg() on the bytes. An MP4 cover fails at image decode; use an image.
  • article_cover.alt and .thumbnail are validated but unused. The draft request only sends cover_media: {media_category, media_id}; neither the alt text nor the thumbnail URL reaches X. article_cover.id is also ignored: the provider uploads the file at article_cover.path itself and uses the returned media id.
  • h3 is intentionally flattened to header-two. The comment in the code says X’s draft endpoint returns 503 for header-three. If X fixes it, the mapping can be restored.
  • Polls are not implemented. PostDetails.poll is ignored entirely. X’s POST /2/tweets accepts a poll object with 2–4 options and duration_minutes 5–10080, but Rustiz never sends it. A poll configured in the UI is silently dropped.
  • Quote posts are not implemented. quote_tweet_id is never sent; X also requires Enterprise for quote posting on self-serve plans.
  • Media alt text is not implemented. MediaContent.alt is ignored for X.
  • max_length Premium detection is weak. The engine looks for an additional setting {title: "Verified", value: true} captured at connect time from GET /2/users/me’s verified boolean. The code requests verified_type but never reads it. A Premium account that is not “verified” with a blue check is capped at 280 even though X would accept long-form posts.
  • Article cap is arbitrary. max_length returns 100_000 for articles. X’s actual article body limit is not documented in the API reference; the engine only uses this for local counting.
  • URL counting differs from X. Rustiz counts characters after HTML stripping using its own logic. X counts every URL as 23 characters and weights emoji/CJK as 2. A post that passes local validation can still be rejected with a length error.
  • Community posting. community must be an https://x.com/i/communities/… URL; the provider sends community_id as the last path segment and share_with_followers: true. A numeric id alone is rejected by validate_settings().
  • reply_settings charset. X accepts following, mentionedUsers, subscribers, verified; everyone is the absence of the field. The Rust enum matches exactly.
  • Analytics are best-effort. Both analytics methods swallow errors and return [] (a warning is logged). analytics() caps the lookback at 100 days (days.min(100)), fetches up to 100 tweets per page, then a second /2/tweets?ids=… call for public_metrics. It emits only two points per metric (start and end) with percentage_change: 5.0 hard-coded, so it is a summary, not a real time series.
  • Post analytics are exact but sparse. GET /2/tweets/{id} with public_metrics returns one point per metric dated today. Missing metrics are skipped.
  • Mentions resolve a single account. mention() looks up /2/users/by/username/{query} and returns at most one entry. A 404/any error returns an empty list, not an error.
  • STRIP_LINKS_FROM_X_POSTS also affects comments and plugs. The stripper removes http(s) URLs and bare domains from a hard-coded TLD list, collapses spaces and trims. Email addresses are preserved by the regex.
  • Error mapping. handle_errors() matches many X error bodies: Unsupported Authentication and "title":"Unauthorized" → refresh; Service Unavailable / Too Many Requests → retry; duplicate content, cashtag limit, 4-item media limit, suspended account, locked account, crypto addresses, invalid media ids, article permission, empty thread entry → bad body with actionable text. A 429 that does not contain the literal string Too Many Requests falls through to the generic HTTP retry policy.
  • Rate limits. POST /2/tweets is 100/15 min per user and 10,000/24 h per app. POST /2/media/upload/initialize|append|finalize is 1,875/15 min per user. GET /2/tweets is 5,000/15 min per user. The Analytics lookback of 100 days with 100 tweets/page can consume a meaningful number of reads.
  • Pay-per-use billing. The app-level cap is 3M post reads/month; writes are billed per request, with a $0.20 surcharge for posts containing a URL. A retry after a failed post is a second billed create unless X rejected it before creating.
  • Version drift. The module pins no API version because X v2 is versionless in the URL. The META_GRAPH_API_VERSION constant in facebook.rs is unrelated to X.

References