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

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

PropertyValue
Identifierthreads
Display nameThreads
Connection modeoauth
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are published as replies with reply_to_id
Pending / async publishingYes. supports_pending = true; containers are created, polled, then published
AnalyticsYes. supports_analytics = true; threads_insights
Post analyticsYes. supports_post_analytics = true; /{post-id}/insights
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job20 (“Threads limits are per profile”)
Character limit (max_length)500 characters
API basehttps://graph.threads.net (THREADS_BASE_URL)
Web basehttps://www.threads.net (THREADS_WEB_BASE_URL)
Token refreshYes. 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.

VariableRequiredDefaultPurpose
THREADS_APP_IDYes—The Threads App ID from App Dashboard → Threads API → Settings. This is not the Facebook app ID.
THREADS_APP_SECRETYes—The Threads App Secret from the same panel. It should be 32 characters; the UI truncates it unless you scroll.
THREADS_BASE_URLNohttps://graph.threads.netGraph host for token exchange, /v1.0/me, containers, publish, insights.
THREADS_WEB_BASE_URLNohttps://www.threads.netOrigin of /oauth/authorize and of the fallback profile URL.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI, wrapped in redirectmeto.com when not HTTPS.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard. Threads fetches media from a public URL.

Provider visibility warning from Postiz: if THREADS_APP_ID or THREADS_APP_SECRET is missing, the provider does not appear in the Add Channel list at all. Restart the process after adding them. rustiz providers --status reports the missing variables directly.

Step-by-Step Developer App Setup

1. Create the app with the Threads use case

  1. Go to https://developers.facebook.com/apps/creation/.
  2. Create an app and choose Access the Threads API (the Threads use case).
  3. 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.
  4. Finish creating the app.

2. Configure API access

  1. On the app dashboard, open Access the Threads API.
  2. Add threads_content_publish. threads_basic is selected automatically. Add threads_manage_replies (reply/comments) and threads_manage_insights (analytics) for the full Rustiz feature set.
  3. 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)

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_URLRegistered redirect URI
https://social.example.comhttps://social.example.com/integrations/social/threads
http://localhost:5000https://redirectmeto.com/http://localhost:5000/integrations/social/threads
http://localhost:4200https://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

  1. Return to the app dashboard and click Finish customization; answer “Yes, I’m finished”. The API may not work until this wizard is complete.
  2. Restart rustiz serve, rustiz worker and rustiz mcp so 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 [].

ScopeWhy Rustiz needs itApp Review for non-testers
threads_basic/me, all endpointsYes
threads_content_publishCreate/publish containersYes
threads_manage_repliesPost replies/commentsYes
threads_manage_insightsAccount and post insightsYes

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

  1. App Dashboard → App roles → Roles → Testers → Add People.
  2. Under Additional Roles for this App, choose Threads Tester.
  3. Enter the Threads username (not the Facebook/Meta developer account).
  4. On threads.com, open Settings → Account → Website permissions → Invites (https://www.threads.com/settings/website_permissions) and accept.
  5. Test the connection. The Open Graph API Explorer can generate a Threads access token for the tester as a sanity check (select threads.net v1).

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

  • customFields routes to connect_custom() and is treated as an OAuth code. Never send it to threads.
  • externalUrl is 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: triggerTool has no Threads-specific helper to call (no page, board or channel picker).
  • settings is 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:

MediaRule
ImageJPEG or PNG, ≤ 8 MB, width 320–1,440 px, aspect ratio ≤ 10:1, sRGB
VideoMOV or MP4, H.264/HEVC + AAC, 23–60 fps, ≤ 1 GB, 5 minutes max, width ≤ 1,920
Carousel2–20 images/videos; a carousel counts as one post
Text≤ 500 characters
LinksAt most 5 unique URLs per post; more fails with THREADS_API__LINK_LIMIT_EXCEEDED
Alt textNot sent by Rustiz (MediaContent.alt is ignored)

How the containers are built:

  • No media → POST /v1.0/{user-id}/threads as multipart/form-data with media_type=TEXT, text, access_token, optional reply_to_id and quote_post_id.
  • One media → POST /v1.0/{user-id}/threads?image_url=… or video_url=…, plus media_type=IMAGE|VIDEO, text, access_token. is_carousel_item is omitted, which means the API treats it as a single post.
  • More than one media → one child container per item with is_carousel_item=true and media_type=IMAGE|VIDEO, then the parent media_type=CAROUSEL&children=<ids>&text=….

Child text quirk: every carousel child is created with the full post text as its text parameter, not just the parent. Threads documents text as 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:

  1. The first entry is published as a text/image/video/carousel post.
  2. Each later entry is created with reply_to_id set to the previous post id (or the root id for the first reply) and published with threads_publish.
  3. comment() uses create_thread_content() then publish_thread(), which waits for the container to be FINISHED (or PUBLISHED) and then publishes.
  4. Replies count against the profile’s 1,000 replies/24 h limit.

Plugs

MethodKindFieldsEffect
autoPlugPost@Plug (run_plug)likesAmount, postReads 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 on IN_PROGRESS. finalize_post() creates the carousel parent (returning Pending again), then calls threads_publish once the parent is ready.
  • PUBLISHED means “already live”. If check_post_status() sees a container in PUBLISHED, 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 a FINISHED parent 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/confirmed handshake. Duplicate protection relies on the container status: a PUBLISHED container is never published again. A crash after threads_publish but 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 using error_message unless it is empty or UNKNOWN; anything else → in progress. A missing status is treated as in-progress until the budget runs out.
  • Authorize URL omits response_type=code. Meta’s Threads documentation lists response_type=code as required on the authorization window. The Rust URL sends client_id, redirect_uri, state and scope only. The endpoint currently defaults to the code flow, but if the login window rejects the request, add &response_type=code to generate_auth_url().
  • Token exchange uses GET, not the documented POST form. Meta’s reference shows POST https://graph.threads.com/oauth/access_token with form fields; Rustiz calls the same endpoint with query parameters via c.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_insights connects 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 calls GET /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.
  • /me has no name. fetch_user_info() requests id,username,threads_profile_picture_url and sets name = username. The channel display name is therefore the handle, not a display name.
  • refresh_token in AuthTokenDetails is the access token. There is no separate refresh token in the Threads API. expires_in is 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 query threads_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 returns total_value, one point is emitted dated today; otherwise it reads values[] and dates them from end_time. Post analytics uses GET /v1.0/{post-id}/insights?metric=views,likes,replies,reposts,quotes and prefers values[0].value, falling back to total_value.value. All errors are swallowed and return []; percentage change is 5.0 (account) or 0.0 (post).
  • threads_insights vs insights. The account endpoint is threads_insights; the per-post endpoint is insights. Mixing them returns a permission/unknown-metric error.
  • Release URL fallback. thread_permalink() reads GET /v1.0/{thread-id}?fields=id,permalink. If that fails, it returns https://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. A 429 falls through to the generic retry policy; there is no X-Business-Use-Case-Usage backoff.
  • 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 by handle_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_ID is 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