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 usestwitter-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
| Property | Value |
|---|---|
| Identifier | x |
| Display name | X |
| Connection mode | oauth (OAuth 1.0a 3-legged flow) |
| Editor | html |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; each extra entry is posted as a reply with reply.in_reply_to_tweet_id |
| Pending / async publishing | Yes. supports_pending = true; media is uploaded and polled, then the post is created behind an arm → confirm → publish handshake |
| Analytics | Yes. supports_analytics = true; may be disabled with DISABLE_X_ANALYTICS |
| Post analytics | Yes. supports_post_analytics = true |
| Mentions | Yes. supports_mentions = true; GET /2/users/by/username/{handle} |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 10 |
Character limit (max_length) | 280 normally, 4,000 when the stored Verified additional setting is true, 100,000 for post_type = "article" |
| API base | https://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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
X_API_KEY | Yes | — | Consumer key (“API Key”) from the X app’s Keys and tokens tab. Signs every OAuth 1.0a request. |
X_API_SECRET | Yes | — | Consumer secret (“API Key Secret”) from the same tab. Used as the HMAC-SHA1 signing key together with the token secret. |
X_API_BASE_URL | No | https://api.x.com | Origin 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_URL | No | FRONTEND_URL | Only 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_URL | Yes in practice | http://localhost:5000 | Base of the callback URI when X_URL is unset. Trailing slashes are stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts the pending OAuth state (code_verifier, state) at rest. |
DISABLE_X_ANALYTICS | No | unset | When true, analytics() and post_analytics() return an empty list without calling X. |
STRIP_LINKS_FROM_X_POSTS | No | unset | When true, URLs and bare domains are removed from post and comment text before publishing. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global 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
- Sign in at https://console.x.com (the old
developer.twitter.comportal redirects here). Accept the Developer Agreement and complete the profile. - Create a Project and an App. The app gets an API Key and API Key Secret (the consumer credentials Rustiz needs).
- 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.
- 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
-
POST {api}/oauth/request_tokenwithoauth_callback={callback}andx_auth_access_type=write, signed with the consumer key/secret. -
The temporary token/secret pair is packed into
code_verifieras"{oauth_token}:{oauth_token_secret}";stateis the temporary token. -
The human is sent to:
{api}/oauth/authenticate?oauth_token={token}&force_login=false -
On return,
POST {api}/oauth/access_tokenwithoauth_tokenandoauth_verifier, signed with the temporary token secret. -
GET /2/users/me?user.fields=username,verified,verified_type,profile_image_url,nameverifies 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:
| Plan | Status | Posting allowance / cost |
|---|---|---|
| Legacy Free | Closed to new developers on 6 Feb 2026 | Was 500 posts/month, 100 reads/month; active users were moved to pay-per-use with a $10 voucher |
| Legacy Basic | Retired for new signups (migrated from June 2026) | Was $200/month, 3,000 posts/month per user, 15,000 reads/month |
| Legacy Pro | Retired 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 developers | Prepaid 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 Utility | Application only | Free 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/tweetsis 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_URLcan 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: ...);--openlaunches 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--timeoutseconds (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 withrustiz channels connect-status <connectionId>orrustiz channels list. --external-urlis meaningless for X and should not be passed.--fieldsis 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
customFieldsis for credential providers. Inconnect.rs, anycustomFieldsobject routes toconnect_custom(), which base64-encodes it as the OAuthcode; X then treats it as an authorization code and fails. Never sendcustomFieldstox.externalUrlis 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
}
| Field | Type | Required in practice | Behaviour |
|---|---|---|---|
post_type | "post" / "article" | No; defaults to post | article switches to the Articles endpoints and HTML content |
article_title | string, non-empty | Yes when post_type=article | Sent as the draft title |
article_status | "draft" / "published" | Yes when post_type=article | draft stops after POST /2/articles/draft; published calls the publish endpoint |
article_cover | object | No | {id, path, alt?, thumbnail?}; uploaded separately as a JPEG tweet_image |
who_can_reply_post | enum | Yes for regular posts | Omitted from the request when everyone; sent as reply_settings otherwise |
community | X community URL | No; empty string allowed | Adds share_with_followers: true and community_id (the last path segment) |
made_with_ai | boolean | No; defaults false | Always sent to POST /2/tweets (and to comments) |
paid_partnership | boolean | No; defaults false | Always 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()
settingsmust be a JSON object.post_typemissing 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_postmust be exactly one ofeveryone,following,mentionedUsers,subscribers,verified. A missing value is an error even though the JSON schema does not mark itrequired. - For
article:article_titlemust be a non-empty string andarticle_statusmust bedraftorpublished. article_cover, when non-null:- must be an object;
idmust be a string (an empty string is accepted — it is typed as a string by the DTO);pathmust be non-empty and end in.png,.jpg,.jpeg,.gif,.webpor.mp4;- if
RESTRICT_UPLOAD_DOMAINSis set,pathmust contain that domain; altmust be a string when present;thumbnail, when a non-empty string, must parse ashttp/https.
made_with_aiandpaid_partnershipmust be boolean or string when present (the validator accepts numeric-looking strings;as_boolthen reads"true"case-insensitively).
check_validity()
Only articles are pre-checked:
- If
post_type != "article"→ no checks. - If any media path contains
.mp4→ “X articles only support images”. - If
article_status != "published"andposts.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
| Media | Rule in the Rust port |
|---|---|
| Image | Any 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 GIF | Path contains .gif; every frame is resized and re-encoded, uploaded as tweet_gif |
| Video | Only 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 cover | Uploaded as JPEG tweet_image and sent as cover_media |
| Alt text | Ignored 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
mediawithContent-Type: application/octet-stream. finalizemay returnprocessing_info; Rustiz pollsGET /2/media/upload?command=STATUS&media_id=….
Articles
Articles are a v2 feature added to X in June 2026:
POST /2/articles/draftwith{title, content_state, cover_media?}.content_stateis Draft.js: blocks plus entities. Rustiz converts the HTML subsetp, h1, h2, h3, ul, ol, li, strong, u, a, imginto blocks:h1→header-one,h2/h3→header-two(header-threeis downgraded because X’s draft endpoint returned 503 for it);ul/ol→unordered-list-item/ordered-list-item;strong→boldinline style;a→linkentity;img→atomicblock with animageentity.
- If
article_status = "draft", the returned article id is stored and the release URL ishttps://x.com/i/articles. - If
article_status = "published",POST /2/articles/{id}/publishis called anddata.post_idbecomes the published post id. The release URL useshttps://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
| Method | Kind | Fields | Effect |
|---|---|---|---|
autoRepostPost | @Plug (run_plug) | likesAmount | Reads 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, post | When 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_SECRETare the consumer key and secret, and the stored token isaccess_token:access_token_secret. The provider’s tests verify the signature against X’s documented worked example (Ls93hJiZbQ3akF3HF3x1Bz8/zU4=). X_URLis the callback origin, not the API origin. The Postiz docs sayX_URLoverrides the API base. Inx.rs,callback_url()readsX_URL, whileapi()readsX_API_BASE_URL. SettingX_URLto a self-hosted X-compatible API therefore breaks the callback instead of redirecting API traffic. UseX_URLonly to point the OAuth callback at an HTTPS tunnel whenFRONTEND_URLis a local address.- No token refresh. OAuth 1.0a tokens are long-lived;
authenticate()storesexpires_in = 999_999_999(about 31.7 years) and an emptyrefresh_token.refresh_token()returns an empty access token withexpires_in: 0, so the engine marks the channelrefreshNeededand asks the user to reconnect if X ever rejects the token. There is no automatic repair. - Token secrets are stored in one string.
access_tokenis"{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 flipsattempting=true, confirmed=falseand returns Pending; the next check witnesses it and setsconfirmed=true; only then isPOST /2/tweetssent. 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
confirmedwas 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 withdefault_wait_ms = 1000and a 7-minute max. - Article image waits have no timeout.
upload_bytes()pollsprocessing_infowithmax_wait_ms = None. If X keeps returning acheck_after_secsvalue and never reachessucceeded/failed, that call can loop indefinitely. Video uploads avoid this by returningstill_processingto the durable workflow instead of waiting insideupload_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. Withmax_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
.mp4but cannot process it.validate_settings()allows.mp4inarticle_cover.path, yet the upload path always callsresize_jpeg()on the bytes. An MP4 cover fails at image decode; use an image. article_cover.altand.thumbnailare validated but unused. The draft request only sendscover_media: {media_category, media_id}; neither the alt text nor the thumbnail URL reaches X.article_cover.idis also ignored: the provider uploads the file atarticle_cover.pathitself and uses the returned media id.h3is intentionally flattened toheader-two. The comment in the code says X’s draft endpoint returns 503 forheader-three. If X fixes it, the mapping can be restored.- Polls are not implemented.
PostDetails.pollis ignored entirely. X’sPOST /2/tweetsaccepts apollobject with 2–4 options andduration_minutes5–10080, but Rustiz never sends it. A poll configured in the UI is silently dropped. - Quote posts are not implemented.
quote_tweet_idis never sent; X also requires Enterprise for quote posting on self-serve plans. - Media alt text is not implemented.
MediaContent.altis ignored for X. max_lengthPremium detection is weak. The engine looks for an additional setting{title: "Verified", value: true}captured at connect time fromGET /2/users/me’sverifiedboolean. The code requestsverified_typebut 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_lengthreturns100_000for 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.
communitymust be anhttps://x.com/i/communities/…URL; the provider sendscommunity_idas the last path segment andshare_with_followers: true. A numeric id alone is rejected byvalidate_settings(). reply_settingscharset. X acceptsfollowing,mentionedUsers,subscribers,verified;everyoneis 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 forpublic_metrics. It emits only two points per metric (start and end) withpercentage_change: 5.0hard-coded, so it is a summary, not a real time series. - Post analytics are exact but sparse.
GET /2/tweets/{id}withpublic_metricsreturns 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_POSTSalso affects comments and plugs. The stripper removeshttp(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 Authenticationand"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. A429that does not contain the literal stringToo Many Requestsfalls through to the generic HTTP retry policy. - Rate limits.
POST /2/tweetsis 100/15 min per user and 10,000/24 h per app.POST /2/media/upload/initialize|append|finalizeis 1,875/15 min per user.GET /2/tweetsis 5,000/15 min per user. TheAnalyticslookback 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_VERSIONconstant infacebook.rsis unrelated to X.
References
- X developer console: https://console.x.com
- Getting access / app creation: https://docs.x.com/x-api/getting-started/getting-access
- OAuth 1.0a overview: https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/overview
- Chunked media upload: https://docs.x.com/x-api/media/quickstart/media-upload-chunked
- Create post (including poll schema): https://docs.x.com/x-api/posts/create-post
- Articles introduction: https://docs.x.com/x-api/articles/introduction
- Create draft article: https://docs.x.com/x-api/articles/create-draft-article
- Publish article: https://docs.x.com/x-api/articles/publish-article
- Rate limits: https://docs.x.com/x-api/fundamentals/rate-limits
- Pay-per-use pricing: https://docs.x.com/x-api/getting-started/pricing
- Counting characters: https://docs.x.com/resources/fundamentals/counting-characters
- Postiz X setup: https://docs.postiz.com/providers/x-twitter
- Postiz X settings: https://docs.postiz.com/public-api/providers/x