Threads (threads)
Threads uses the Threads API at graph.threads.net with a Threads user
access token. Publishing is a two- or three-step container flow: create media
containers, optionally create a carousel parent, then threads_publish. Rustiz
splits this across the durable workflow (post_pending → check_post_status →
finalize_post) so a crash after publish does not republish.
- Rust source:
crates/rustiz-providers/src/threads.rs - Upstream port:
threads.provider.ts - Editor: Normal
- Connection mode:
oauth(Threads authorization window)
Overview
| Property | Value |
|---|---|
| Identifier | threads |
| Display name | Threads |
| Connection mode | oauth |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are published as replies with reply_to_id |
| Pending / async publishing | Yes. supports_pending = true; containers are created, polled, then published |
| Analytics | Yes. supports_analytics = true; threads_insights |
| Post analytics | Yes. supports_post_analytics = true; /{post-id}/insights |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 20 (“Threads limits are per profile”) |
Character limit (max_length) | 500 characters |
| API base | https://graph.threads.net (THREADS_BASE_URL) |
| Web base | https://www.threads.net (THREADS_WEB_BASE_URL) |
| Token refresh | Yes. refresh_cron = true; th_refresh_token |
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["THREADS_APP_ID", "THREADS_APP_SECRET"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
THREADS_APP_ID | Yes | — | The Threads App ID from App Dashboard → Threads API → Settings. This is not the Facebook app ID. |
THREADS_APP_SECRET | Yes | — | The Threads App Secret from the same panel. It should be 32 characters; the UI truncates it unless you scroll. |
THREADS_BASE_URL | No | https://graph.threads.net | Graph host for token exchange, /v1.0/me, containers, publish, insights. |
THREADS_WEB_BASE_URL | No | https://www.threads.net | Origin of /oauth/authorize and of the fallback profile URL. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the redirect URI, wrapped in redirectmeto.com when not HTTPS. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global upload guard. Threads fetches media from a public URL. |
Provider visibility warning from Postiz: if
THREADS_APP_IDorTHREADS_APP_SECRETis missing, the provider does not appear in the Add Channel list at all. Restart the process after adding them.rustiz providers --statusreports the missing variables directly.
Step-by-Step Developer App Setup
1. Create the app with the Threads use case
- Go to https://developers.facebook.com/apps/creation/.
- Create an app and choose Access the Threads API (the Threads use case).
- Add Business details or skip. If the wizard says “There are no business portfolios available to connect to this app”, enable Two-Factor Authentication in Meta Business Suite and retry.
- Finish creating the app.
2. Configure API access
- On the app dashboard, open Access the Threads API.
- Add
threads_content_publish.threads_basicis selected automatically. Addthreads_manage_replies(reply/comments) andthreads_manage_insights(analytics) for the full Rustiz feature set. - Open the Settings tab and copy:
- Threads App ID →
THREADS_APP_ID - Threads App Secret →
THREADS_APP_SECRET(use Show and copy all 32 characters)
- Threads App ID →
3. Redirect URI (exact, including redirectmeto.com)
Threads::redirect_uri() is:
#![allow(unused)]
fn main() {
let frontend = self.ctx.config.frontend_url();
let frontend = if frontend.contains("https") {
frontend
} else {
format!("https://redirectmeto.com/{frontend}")
};
format!("{frontend}/integrations/social/threads")
}
FRONTEND_URL | Registered redirect URI |
|---|---|
https://social.example.com | https://social.example.com/integrations/social/threads |
http://localhost:5000 | https://redirectmeto.com/http://localhost:5000/integrations/social/threads |
http://localhost:4200 | https://redirectmeto.com/http://localhost:4200/integrations/social/threads |
Register this exact value in Threads API → Settings → OAuth redirect URIs. The form also requires an Uninstall Callback URL and a Delete Callback URL; both can be the same value. Rustiz does not implement either callback, so they are only there to satisfy the form.
Click-to-activate quirk: Postiz’s guide notes that the redirect URI must be clicked in the dashboard to become active; typing it and saving without clicking can leave the form in a state where it looks saved but the URI is not registered.
The contains("https") test is a substring check, not a scheme parse. A
FRONTEND_URL that includes https anywhere skips the redirectmeto wrapper.
Like the Instagram standalone provider, the wrapper is evaluated per request;
change FRONTEND_URL and you must re-register the URI.
4. Finish customization and restart
- Return to the app dashboard and click Finish customization; answer “Yes, I’m finished”. The API may not work until this wizard is complete.
- Restart
rustiz serve,rustiz workerandrustiz mcpso the new environment variables are loaded.
5. Scopes requested and checked
Threads::scopes() returns, in this order:
#![allow(unused)]
fn main() {
vec![
"threads_basic",
"threads_content_publish",
"threads_manage_replies",
"threads_manage_insights",
]
}
The authorize URL joins them with commas:
https://www.threads.net/oauth/authorize
?client_id={THREADS_APP_ID}
&redirect_uri={redirect_uri}
&state={state}
&scope=threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights
check_scopes() is never called by this provider. The token exchange
response is not inspected for granted scopes. A user who declines
threads_manage_insights still connects; analytics then fails at call time
and returns [].
| Scope | Why Rustiz needs it | App Review for non-testers |
|---|---|---|
threads_basic | /me, all endpoints | Yes |
threads_content_publish | Create/publish containers | Yes |
threads_manage_replies | Post replies/comments | Yes |
threads_manage_insights | Account and post insights | Yes |
Threads testers can grant these at any time while the app is in Development mode. For anyone without a role, each permission must be approved through App Review and the app must be published.
6. Threads testers
- App Dashboard → App roles → Roles → Testers → Add People.
- Under Additional Roles for this App, choose Threads Tester.
- Enter the Threads username (not the Facebook/Meta developer account).
- On threads.com, open Settings → Account → Website permissions → Invites (https://www.threads.com/settings/website_permissions) and accept.
- Test the connection. The Open Graph API Explorer can generate a Threads
access token for the tester as a sanity check (select
threads.netv1).
If the invite is not accepted, the login window fails with an authorization error even though the app credentials are correct.
7. Development mode vs Live / App Review
- Development mode (default): only Threads testers can authorize and publish. This is enough for a self-hosted single-user install.
- Live mode: anyone can authorize, but only permissions approved through App Review are active. Advanced Access requires App Review; review needs a screencast of the Threads login and a published post/reply.
- A permission grant by a public Threads profile is valid for 90 days and can be extended by refreshing the token. A private profile’s grant cannot be extended; the user must re-authorize.
8. Sandbox / testing
Threads has no separate sandbox host. Use a Threads tester account, a test
app in Development mode, and publish test posts. Container publishing is
visible immediately after threads_publish; there is no draft state.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect threads
rustiz channels connect threads --open
rustiz channels connect threads --timeout 600
The login URL is printed to stderr. With a local FRONTEND_URL the CLI serves
the callback on the frontend port and waits; with a public HTTPS
FRONTEND_URL it prints the URL and exits.
is_between_steps is false, so the channel is finished when the callback
returns. No setup tool is needed.
MCP connectChannelTool
{ "provider": "threads" }
Response:
{
"connected": false,
"pending": true,
"connectionId": "…",
"url": "https://www.threads.net/oauth/authorize?client_id=…&redirect_uri=…&state=…&scope=threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
Poll with { "connectionId": "…" }. integrationSchema reports oauth,
headless: false, requiresHuman: true, requiresExternalUrl: false.
There is no triggerTool / channelSetupTool follow-up.
What not to send
customFieldsroutes toconnect_custom()and is treated as an OAuth code. Never send it tothreads.externalUrlis unused.
Post Settings & Media Validation
settings_schema()
Threads exposes no provider-specific settings:
{
"type": "object",
"properties": {},
"additionalProperties": true
}
validate_settings()is the trait default: every object is accepted.check_validity()is the trait default: no pre-flight media checks.functions()is the trait default:triggerToolhas no Threads-specific helper to call (no page, board or channel picker).settingsis effectively{"__type": "threads"}plus anything the caller adds; unknown keys are ignored by the provider.
Not exposed even though the Threads API supports them: is_made_with_ai,
topic_tag, link_attachment, gif_attachment, polls, spoilers, location
tagging, and quote posts. quote_post_id is implemented in the low-level
create_text_content() helper but no public setting or post field feeds it.
Character limit
max_length() is 500 for every post. Threads counts emoji as their UTF-8
byte length, while Rustiz’s engine counts Unicode characters; a post full of
emoji can pass local validation and still be rejected with “text must be at
most 500 characters”.
Media requirements
Rustiz does not validate media for Threads. The platform requires:
| Media | Rule |
|---|---|
| Image | JPEG or PNG, ≤ 8 MB, width 320–1,440 px, aspect ratio ≤ 10:1, sRGB |
| Video | MOV or MP4, H.264/HEVC + AAC, 23–60 fps, ≤ 1 GB, 5 minutes max, width ≤ 1,920 |
| Carousel | 2–20 images/videos; a carousel counts as one post |
| Text | ≤ 500 characters |
| Links | At most 5 unique URLs per post; more fails with THREADS_API__LINK_LIMIT_EXCEEDED |
| Alt text | Not sent by Rustiz (MediaContent.alt is ignored) |
How the containers are built:
- No media →
POST /v1.0/{user-id}/threadsasmultipart/form-datawithmedia_type=TEXT,text,access_token, optionalreply_to_idandquote_post_id. - One media →
POST /v1.0/{user-id}/threads?image_url=…orvideo_url=…, plusmedia_type=IMAGE|VIDEO,text,access_token.is_carousel_itemis omitted, which means the API treats it as a single post. - More than one media → one child container per item with
is_carousel_item=trueandmedia_type=IMAGE|VIDEO, then the parentmedia_type=CAROUSEL&children=<ids>&text=….
Child text quirk: every carousel child is created with the full post text as its
textparameter, not just the parent. Threads documentstextas optional on children and normally ignores it, but if a child text causes a validation error in a future API version, this is the place to look.
Threads / comments
supports_comments = true. A multi-entry post:
- The first entry is published as a text/image/video/carousel post.
- Each later entry is created with
reply_to_idset to the previous post id (or the root id for the first reply) and published withthreads_publish. comment()usescreate_thread_content()thenpublish_thread(), which waits for the container to beFINISHED(orPUBLISHED) and then publishes.- Replies count against the profile’s 1,000 replies/24 h limit.
Plugs
| Method | Kind | Fields | Effect |
|---|---|---|---|
autoPlugPost | @Plug (run_plug) | likesAmount, post | Reads GET /v1.0/{post-id}/insights?metric=likes; when the first value is ≥ likesAmount, posts a text reply with reply_to_id and publishes it |
run_post_plug() always returns Unsupported. likesAmount accepts a string
or number; a missing value becomes 0.
Implementation Notes & Gotchas
- Three-step durable publish.
post_pending()creates either one container (step: "container") or all carousel child containers (step: "children").check_post_status()reads one status per child (or the single container) and returns Pending onIN_PROGRESS.finalize_post()creates the carousel parent (returning Pending again), then callsthreads_publishonce the parent is ready. PUBLISHEDmeans “already live”. Ifcheck_post_status()sees a container inPUBLISHED, it returns Completed immediately. For a single container it uses the container id as the post id and the profile URL as the release URL — it does not call the API again. For carousel parents the same is true. This is the crash-recovery path, not the normal path.- Carousel parent is polled. After
finalize_post()creates the parent, it returns Pending; the workflow checks the parent, and only aFINISHEDparent is published. A crash between parent creation and publish leaves an invisible parent that expires. - Arm/confirm is not used. Unlike X/Pinterest/Facebook stories, Threads has
no
attempting/confirmedhandshake. Duplicate protection relies on the container status: aPUBLISHEDcontainer is never published again. A crash afterthreads_publishbut before recording the result can therefore report the container id rather than the published post id; the post is still live. check_loaded()waits up to ~5.5 minutes (150 iterations × 2.2 s) and then errors “Threads took too long to process the media, please try again”.publish_thread()calls it before every publish, including comments.- Status states.
FINISHED→ ready;PUBLISHED→ ready/already live;ERROR/EXPIRED→ error usingerror_messageunless it is empty orUNKNOWN; anything else → in progress. A missingstatusis treated as in-progress until the budget runs out. - Authorize URL omits
response_type=code. Meta’s Threads documentation listsresponse_type=codeas required on the authorization window. The Rust URL sendsclient_id,redirect_uri,stateandscopeonly. The endpoint currently defaults to the code flow, but if the login window rejects the request, add&response_type=codetogenerate_auth_url(). - Token exchange uses GET, not the documented POST form. Meta’s reference
shows
POST https://graph.threads.com/oauth/access_tokenwith form fields; Rustiz calls the same endpoint with query parameters viac.get(...). Meta accepts this for Threads today. If you see an OAuth error at this step, switching to a POST form is the first compatibility change to try. - No scope check. As noted above, the provider never verifies granted
scopes. A token without
threads_manage_insightsconnects fine and analytics silently returns[](the error is logged). - Token refresh is real.
refresh_cron = true; shortly before the hard coded 58-day expiry the hourly sweep callsGET /refresh_access_token?grant_type=th_refresh_token&access_token=…, then re-reads/me. Meta’s long-lived token is valid 60 days; refreshing extends it (and the 90-day permission grant for public profiles). - Long-lived exchange has no client id. The second step is
GET /access_token?grant_type=th_exchange_token&client_secret=…&access_token=…. Only the app secret and short-lived token are sent, matching Meta’s Threads documentation. /mehas noname.fetch_user_info()requestsid,username,threads_profile_picture_urland setsname = username. The channel display name is therefore the handle, not a display name.refresh_tokeninAuthTokenDetailsis the access token. There is no separate refresh token in the Threads API.expires_inis hard-coded to 58 days.max_concurrent_job = 20. Threads limits publishing per profile to 250 posts/24 h and 1,000 replies/24 h. A carousel counts as one post. The worker concurrency is separate from those quotas; the provider does not querythreads_publishing_limit.- Analytics quirks. Account analytics uses
GET /v1.0/{id}/threads_insights?metric=views,likes,replies,reposts,quotes&period=day&since=…&until=…. If a metric returnstotal_value, one point is emitted dated today; otherwise it readsvalues[]and dates them fromend_time. Post analytics usesGET /v1.0/{post-id}/insights?metric=views,likes,replies,reposts,quotesand prefersvalues[0].value, falling back tototal_value.value. All errors are swallowed and return[]; percentage change is5.0(account) or0.0(post). threads_insightsvsinsights. The account endpoint isthreads_insights; the per-post endpoint isinsights. Mixing them returns a permission/unknown-metric error.- Release URL fallback.
thread_permalink()readsGET /v1.0/{thread-id}?fields=id,permalink. If that fails, it returnshttps://www.threads.net/@{profile}. A live post is never failed because of a missing permalink. - Error mapping.
Error validating access token→ refresh;2207051→ “We restrict certain activity to protect our community”;4279013→ user restricted; “The media could not be fetched from this URI” → bad media URL;4279009→ retry (“could not find the media container”); “text must be at most 500 characters” → bad body. A429falls through to the generic retry policy; there is noX-Business-Use-Case-Usagebackoff. - Link limit. Threads rejects posts with more than 5 unique URLs. Rustiz
does not count links, so a link-heavy post fails at container creation with
THREADS_API__LINK_LIMIT_EXCEEDED(not mapped byhandle_errors; it surfaces as the raw Graph error). - Carousel limit is not checked. Rustiz accepts any number of media; the API rejects fewer than 2 or more than 20 children.
- Media must be public. Threads (like Instagram) downloads the image/video from the URL. A Rustiz instance on a private network needs a public media host or tunnel.
THREADS_APP_IDis not the Facebook app ID. Using the Facebook pair produces an “Invalid client_id” or “App not authorized” error at the login window. Copy the values from the Threads API Settings tab.- No sandbox. Test with Threads testers; Development mode hides content from non-role users.
References
- Threads API get started: https://developers.facebook.com/docs/threads/get-started
- Threads posts (single, carousel, media specs): https://developers.facebook.com/docs/threads/posts
- Threads API overview and rate limits: https://developers.facebook.com/docs/threads/overview
- Long-lived tokens and refresh: https://developers.facebook.com/docs/threads/get-started/long-lived-tokens
- App modes: https://developers.facebook.com/docs/development/build-and-test/app-modes
- App Review: https://developers.facebook.com/docs/app-review
- Postiz Threads setup: https://docs.postiz.com/providers/threads
- Postiz Threads settings: https://docs.postiz.com/public-api/providers/threads