The Threads API: Publishing to Threads Programmatically
Publishing to Threads from your own code takes exactly two calls: one that creates a media container holding your text and any attachments, and a second that publishes that container to the feed. The Threads API is a Graph-style REST interface hosted at graph.threads.net, authorized with OAuth tokens scoped to a single Threads user, and it limits most accounts to 250 published posts per rolling 24 hours.
That two-step shape is the single most important thing to internalize before you write any code. Every other social network you have integrated probably accepts a post in one request. Threads inherited the container model from Instagram's Content Publishing API, which means your client has to hold an intermediate identifier, decide when the container is ready, and then commit it. Get that flow right and the rest of the integration is small. Get it wrong and you will spend your first week debugging posts that silently never appear.
What is the Threads API and what can you publish with it?
The Threads API is Meta's official programmatic interface for the Threads network. It exposes a small surface compared to the older Facebook Graph endpoints: publishing, reading your own posts, reading and replying to conversations, moderation of replies, and insights. It does not give you a firehose, a global search endpoint, or the ability to read arbitrary users' timelines. If your product idea depends on listening to everything happening on Threads, the official API is not the tool.
What it does well is first-party publishing. An authenticated user grants your app permission, and your app can then create text posts, image posts, video posts, and carousels on that user's behalf, reply to threads, quote other posts, control who is allowed to reply, and pull back view and engagement counts afterward.
The permission scopes you will encounter:
| Scope | What it unlocks |
|---|---|
threads_basic | Required for every request. Profile fields and reading your own posts. |
threads_content_publish | Creating and publishing containers. The scope that matters for posting. |
threads_manage_replies | Replying as the user, hiding replies, controlling reply permissions. |
threads_read_replies | Reading the reply tree under your own posts. |
threads_manage_insights | Per-post and per-account metrics. |
Request only what you use. Meta's app review process asks you to justify each scope with a screen recording of the feature in your product, and an unused scope is an easy rejection. If you are only scheduling outbound posts, threads_basic plus threads_content_publish is the whole list.
How does the container and publish flow actually work?
The flow has two required steps and one strongly recommended step in between.
Step one: create the container. You POST to /{threads-user-id}/threads with a media_type of TEXT, IMAGE, VIDEO, or CAROUSEL, plus the payload for that type. A text post carries a text parameter. An image post carries image_url. A video post carries video_url. The response is a single numeric id, which is the creation identifier for the container. Nothing is visible on Threads yet.
POST https://graph.threads.net/v1.0/{threads-user-id}/threads
?media_type=TEXT
&text=Shipping notes for this week
&access_token={token}
{ "id": "17999999999999999" }
Step two, the one people skip: wait and check status. For text-only posts the container is ready almost immediately. For images and especially video, Meta has to fetch your media from the URL you supplied, transcode it, and mark the container as ready. Meta's own guidance is to wait roughly 30 seconds before attempting to publish a media container. Rather than sleeping blindly, poll the container:
GET https://graph.threads.net/v1.0/{creation-id}?fields=status,error_message
The status field returns one of IN_PROGRESS, FINISHED, ERROR, EXPIRED, or PUBLISHED. Only publish on FINISHED. On ERROR, read error_message, because that is where you find out that your image was a WebP, that the host returned a 403 to Meta's fetcher, or that the video codec was rejected.
Step three: publish. POST to /{threads-user-id}/threads_publish with creation_id set to the container identifier. The response contains the identifier of the live Threads post, which is what you store for later insight lookups and for building the permalink.
POST https://graph.threads.net/v1.0/{threads-user-id}/threads_publish
?creation_id=17999999999999999
&access_token={token}
Two properties of containers deserve a place in your design. Containers expire, generally 24 hours after creation, so a container is not a durable scheduling primitive. Do not create containers at compose time and publish them a week later. Keep your queue in your own database and create the container only in the minutes before publish time. And publishing is not idempotent in a way you should rely on, so record the returned post identifier immediately and guard your publish step with a per-container lock. A retry that fires while the first attempt is still in flight is the classic cause of accidental double posts.
How do Threads access tokens work?
Threads uses a two-tier token model that will feel familiar if you have integrated Instagram.
The OAuth authorization redirect returns a short-lived code, which you exchange for a short-lived access token valid for about one hour. That token is only useful for immediately upgrading. You call the token exchange endpoint with grant_type=th_exchange_token and your app secret, and you receive a long-lived token valid for about 60 days.
Long-lived tokens are refreshable. You call the refresh endpoint with grant_type=th_refresh_token, and you get a fresh 60-day token. There is a catch that bites teams in testing: a token must be at least 24 hours old before it can be refreshed. If you build a refresh job that runs against a token you minted an hour ago, it fails, and it fails in a way that looks like a credential problem rather than a timing rule.
A practical refresh policy that avoids all of this:
- Store
expires_atalongside every token, computed from theexpires_inthe API returned rather than assumed. - Run a daily job that refreshes any token expiring in the next 10 days.
- Never refresh a token younger than 48 hours.
- On a failed refresh, mark the connection as needing reauthorization and surface that in your product's UI instead of retrying silently. A user who does not know their connection dropped will assume your scheduler is broken.
Tokens are per-user and per-app. There is no account-level bearer token that publishes for everyone, so a multi-tenant product needs encrypted per-user token storage and a reconnect path. This is the same operational shape as the tokens described in our Bluesky posting API guide and the app-password model in Mastodon posting automation, but with a much shorter expiry window than either, so the refresh job is not optional.
What are the rate limits and media constraints?
Meta publishes both a posting quota and per-media constraints. The values below reflect the documented limits at the time of writing. Meta revises them, so treat the API as the source of truth rather than any article, including this one.
| Constraint | Documented value |
|---|---|
| Published posts per user | 250 per rolling 24 hours |
| Replies per user | 1,000 per rolling 24 hours |
| Text length | 500 characters |
| Carousel children | 2 to 20 items |
| Image formats | JPEG and PNG |
| Image size | Up to roughly 8 MB |
| Video formats | MOV and MP4 |
| Video length | Up to roughly 5 minutes |
| Container lifetime | About 24 hours before expiry |
| Link attachments | One per text post |
You do not have to guess where an account sits against its quota. The API exposes GET /{threads-user-id}/threads_publishing_limit, which returns quota usage and the configured cap. A scheduler should check that endpoint before a burst rather than discovering the ceiling through a wall of errors. Application-level rate limiting surfaces as error code 4 and related codes, and the correct response is exponential backoff with jitter, not an immediate retry loop.
The 500-character limit is the constraint that shapes content pipelines most. It sits between Bluesky's 300 and LinkedIn's 3,000, which means a single piece of copy almost never fits every network cleanly. Any serious cross-posting layer has to adapt text per destination rather than truncate, a problem covered in more depth in our cross-posting tool guide.
One more constraint that is easy to miss: media is fetched by URL, not uploaded as multipart form data. Your image and video URLs must be publicly reachable by Meta's servers over HTTPS, with no signed-URL expiry shorter than your processing window and no user-agent filtering that blocks the fetcher. Media behind an authenticated CDN path is the most common cause of an ERROR container status that otherwise looks inexplicable.
How do you publish carousels, replies, and quote posts?
Carousels extend the container model by one level. You first create each child container with is_carousel_item=true, collecting the returned identifiers. You then create a parent container with media_type=CAROUSEL and a children parameter containing the comma-separated child identifiers, plus optional text. Finally you publish the parent. Children are never published individually. A carousel that fails usually fails at one child, so validate each child's status before assembling the parent.
Replies are ordinary containers with a reply_to_id parameter naming the post you are replying to. This is how you build threaded series: publish the first post, capture its identifier, then create the next container with reply_to_id set to that identifier, publish, and repeat. Because each link in the chain depends on the previous publish succeeding, a thread publisher needs to store its progress. If post three of five fails, you want to resume at three rather than restart at one and duplicate the opening.
Quote posts use quote_post_id, pointing at the post you are quoting.
Reply control is set at container creation with reply_control, accepting everyone, accounts_you_follow, or mentioned_only. For accounts that publish announcements and do not want to moderate a comment section, setting this at creation time is far cheaper than moderating afterward.
Link attachments apply to text posts through the link_attachment parameter, which renders a preview card. Only one link per post is supported. If your copy contains several URLs, decide deliberately which one becomes the card.
What should you build around the API, not inside it?
The API gives you publish. Everything that makes publishing dependable lives in your own code.
A durable queue with explicit state. Rows should carry a status such as queued, container created, published, or failed, plus the container identifier, the published post identifier, and the failure reason. Storing the reason as human-readable text is what lets you show a user "the image URL returned 403 to Meta's fetcher" instead of a generic failure.
A missed-window policy. If your worker was down when a post was due, publishing it four hours late is often worse than skipping it. Pick an expiry window, apply it consistently, and record skips as their own status.
Idempotency. Every publish should be keyed so that a retry after a network timeout cannot create a second post. Check for an existing published identifier before calling publish again.
Per-network adaptation. The same idea needs a different shape at 500 characters on Threads, 300 on Bluesky, and long form on LinkedIn. Building this once as a content adaptation step, rather than per integration, is the difference between adding a network in an afternoon and rewriting your pipeline. The same architecture question comes up in automated social media posting and in Twitter automatic posting.
Observability. Log the container identifier, the status transitions, and the final post identifier for every attempt. When a user asks why Tuesday's post never appeared, the answer should be one query away.
If you would rather not maintain that layer per network, Skopx's Social Autopilot covers it as a product feature. It publishes to LinkedIn, Facebook Pages, Reddit, Instagram, X, Threads, Bluesky, Mastodon, Telegram, Discord, an email newsletter sent through your own Resend account, and the Skopx community feed. Content is generated per batch and adapted to each network's character limit, so the 500-character Threads version and the 300-character Bluesky version come from the same brief rather than from a truncation. Skopx connects to nearly 1,000 business tools overall, with Solo at $5 per month and Team at $16 per seat per month, and AI runs on your own key with zero markup or on the included allowance.
How does the Threads API compare to other posting APIs?
The differences matter when you are sequencing which integration to build first.
| Network | Auth model | Publish shape | Text ceiling |
|---|---|---|---|
| Threads | OAuth, 60-day refreshable token | Two-step container then publish | 500 characters |
| OAuth via Facebook login | Two-step container then publish | 2,200 caption | |
| Bluesky | App password, session tokens | Single record creation | 300 characters |
| Mastodon | App token per instance | Single status POST | Instance defined, often 500 |
| Telegram | Bot token, no expiry | Single sendMessage | 4,096 characters |
| Discord | Webhook URL, no expiry | Single webhook POST | 2,000 characters |
Threads and Instagram sit at the demanding end because of the container model and token refresh. Telegram and Discord sit at the easy end because a webhook or bot token does not expire and the post is one request. If you are building a multi-network publisher and want early wins, start with Discord webhook announcements or Telegram channel automation, then take on Threads once your queue and retry logic are proven. The container flow also transfers directly to Instagram, which is worth knowing before you read our Instagram scheduling tool guide.
What breaks in production, and how do you catch it?
Four failure modes account for most Threads integration incidents.
Expired tokens. Sixty days passes quietly. A refresh job that fails silently means every scheduled post fails at once on day 61. Alert on refresh failures, not just publish failures.
Media the fetcher cannot reach. Signed URLs that expire in 60 seconds, storage buckets that block unknown user agents, and hosts that return HTML error pages with a 200 status all produce container errors. Test with a plain public HTTPS URL first to isolate whether the problem is your media host or your payload.
Publishing an unfinished container. Publishing while status is IN_PROGRESS returns an error that reads like a permissions problem. Always poll for FINISHED.
Quota exhaustion during backfill. Importing a content calendar and firing 300 posts in one afternoon hits the 250-post ceiling. Check threads_publishing_limit and spread the work.
Beyond publishing, keep an eye on what your published content is doing for discovery. Posts on public social networks are increasingly the raw material that AI assistants cite when they answer questions about a category, which is why measuring where you are named is worth doing alongside your posting metrics. Our guides on AI visibility tracking and brand mentions monitoring in the AI era cover that side.
Frequently Asked Questions
Do I need Meta app review to use the Threads API?
For publishing on behalf of your own account while your app is in development mode, you can work with test users and the app owner's account without full review. To publish for other people's accounts in production, your app needs the relevant permissions approved through Meta's app review, with a demonstration of how each requested scope is used in your product. Build and record that demo flow before you submit, because the most common rejection reason is a reviewer being unable to reproduce the feature.
Why does my container return FINISHED but the post never appears?
If the container reached FINISHED and the publish call returned a post identifier, the post exists. Fetch it by identifier with a permalink field to confirm. If the publish call itself errored, the container stays unpublished and expires after roughly 24 hours. The other common case is a retry that published a container which had already been published, in which case the second call errors while the first post is live.
Can I schedule posts through the Threads API itself?
No. There is no server-side scheduling parameter. The API publishes when you call it. Scheduling is entirely your responsibility, which means a queue, a worker, a timezone-correct trigger, and a policy for what happens when a run is missed. Remember that containers expire in about 24 hours, so create the container close to publish time rather than at compose time.
What happens if I hit the 250-post daily limit?
Publish calls start failing with rate limit errors until the rolling window clears. The limit is per user, not per app, so one heavy account does not block others in a multi-tenant product. Query threads_publishing_limit before large batches, back off exponentially on rate limit errors, and design your queue to reschedule rather than drop when the ceiling is reached.
Can the Threads API read other people's posts?
Only in narrow ways. You can read your own posts, the replies underneath them, and mentions of your account. There is no general search or timeline read endpoint for arbitrary accounts. Products that need broad listening across a network typically combine first-party APIs with public sources, an approach we describe in the social media scheduling tools guide.
How do I build a multi-post thread reliably?
Publish the root post, store its identifier, then create each subsequent container with reply_to_id set to the identifier of the post directly above it, publishing one at a time and storing each result. Never fire the chain in parallel, because reply ordering depends on the parent existing first. Persist your position in the chain so that a failure at post three resumes at three instead of duplicating posts one and two.
The short version
Two calls to publish, containers that expire in a day, tokens that expire in 60 days and refuse to refresh in their first 24 hours, 500 characters of text, and 250 posts per day. That is the whole contract. The engineering effort in a Threads integration is not in the API surface, which is small and well documented. It is in the queue, the retry semantics, the token lifecycle, and the per-network content adaptation that sits above all of it. Build those once, and Threads becomes one destination among many rather than a project of its own.
Skopx Team
The Skopx engineering and product team