Engineering

Threads API: the container flow, the limits, and the failures

The Threads API is a two-step container flow with a 500-character body, containers that expire in 24 hours, and one undocumented publish race that will cost you a day. Every capability, limit and failure mode, each linked to the Meta page it came from.

The Threads API is Meta's official write API for Threads. It is served from graph.threads.net and graph.threads.com — both answer on v1.0 — and it is its own surface, not an edge on the Instagram Graph API (Threads API overview). It does three things: publishes posts, manages replies, and reads insights.

It is also the newest thing Meta ships, and that shows. Publishing is a two-step container flow. Containers expire after 24 hours. The text ceiling is 500 characters. And the error you will hit most often in production is not on Meta's troubleshooting page at all.

This is the reference we wanted while building Threads support: every capability with its endpoint and scope, every published limit, and the failure modes with what to do about each. Every number here is linked to the Meta page it came from.

One naming note, because the search results are a mess: Meta ships this as the Threads API. You will also see it called the Meta Threads API, the Instagram Threads API, or the Threads app API. Same thing. There is no separate Instagram-side endpoint for Threads — graph.threads.net is the whole surface.

What the Threads API can do

The full write and read surface, with the scope each call needs (permissions reference).

Capability

Endpoint

Scope

Publish text, image, video or carousel

POST /me/threads then POST /me/threads_publish

threads_content_publish

Reply to a post or to another reply

Same two calls, with reply_to_id on the container

threads_content_publish

Repost

POST /{media-id}/repost

threads_content_publish

Quote post

Container with quote_post_id

threads_content_publish

Read a whole conversation

GET /{media-id}/conversation

threads_read_replies

Hide or unhide a reply

POST /{reply-id}/manage_reply

threads_manage_replies

List replies awaiting approval

GET /{media-id}/pending_replies

threads_read_replies

Approve or ignore a pending reply

POST /{reply-id}/manage_pending_reply

threads_manage_replies

Post insights

GET /{media-id}/insights

threads_manage_insights

Account insights and follower demographics

GET /{threads-user-id}/threads_insights

threads_manage_insights

Delete a post or a reply

DELETE /{media-id}

threads_delete

Remaining publishing quota

GET /{threads-user-id}/threads_publishing_limit

threads_basic

Two things it does not do, and both surprise people:

  • You cannot edit published text. There is no edit endpoint for a post or for a reply. Delete and repost is the only correction path.
  • You cannot build a reader for other people's Threads. Meta scopes the API to creating content and displaying it "solely to the person who created it" (overview). The conversation edge gives you the replies on your own posts, not a public firehose.

App setup, scopes and the token clock

You need a Meta app with the Threads use case configured, which gives you an App ID and an App Secret. The click-path through Meta's dashboard — products, redirect URIs, the deauthorize and data-deletion callbacks Meta refuses to save a redirect URI without — is written up step by step in our Threads setup guide.

From there the OAuth flow has four moves, and the last one is the one that bites:

  1. Authorize. Send the user to https://threads.com/oauth/authorize with client_id, redirect_uri, scope and response_type=code. threads_basic is mandatory in every scope list (getting access tokens).
  2. Exchange the code. POST /oauth/access_token with grant_type=authorization_code. Authorization codes are valid for one hour.
  3. Trade up to a long-lived token. GET /access_token?grant_type=th_exchange_token. That token is good for 60 days (long-lived tokens).
  4. Refresh it, forever. GET /refresh_access_token?grant_type=th_refresh_token. The token must be at least 24 hours old and not yet expired; a refreshed token is valid for 60 days from the refresh.

That fourth step is not optional plumbing, it is the whole integration. A long-lived token that goes 60 days without a refresh expires and cannot be renewed — your only recovery is putting that user back through the OAuth window. Nothing warns you. The connection works for two months and then every call returns 401.

Write the refresh job before you write the publish path. The publish path fails loudly. The missing refresh job fails sixty days later, quietly, on an account you have forgotten about.

The scopes

Scope

What it covers

threads_basic

Required for every Threads endpoint

threads_content_publish

The publishing endpoints only

threads_read_replies

GET calls on reply endpoints

threads_manage_replies

POST calls on reply endpoints

threads_manage_insights

GET calls on insights endpoints

Meta's overview also lists threads_delete and threads_location_tagging for deletion and location tagging; the Get Started permissions table covers the five above. Request the narrow set you actually use — every extra scope is another thing app review will ask you to justify.

Speaking of which: before anyone without a role on your app can authorize it, the app needs App Review and published status. That is a real calendar dependency, and it is the part teams underestimate. We wrote up what approval looks like on each platform if you are sizing the work.

Posting: create a container, wait, then publish

There is no single-call post. Every publish is create-then-publish, the same shape as Instagram's content publishing (Threads posts). Here is the whole thing against the raw API:

bash
1# 1. Create the container. Returns a creation_id, publishes nothing.
2curl -s -X POST "https://graph.threads.net/v1.0/me/threads" \
3  -d "media_type=IMAGE" \
4  -d "image_url=https://cdn.example.com/ship-it.jpg" \
5  -d "text=Shipped the thing. Threads support is live." \
6  -d "reply_control=everyone" \
7  -d "access_token=$THREADS_TOKEN"
8# {"id":"17900000000000001"}
9
10# 2. Poll until the container is ready. Never skip this on media.
11curl -s "https://graph.threads.net/v1.0/17900000000000001\
12?fields=status,error_message&access_token=$THREADS_TOKEN"
13# {"status":"FINISHED","id":"17900000000000001"}
14
15# 3. Publish it.
16curl -s -X POST "https://graph.threads.net/v1.0/me/threads_publish" \
17  -d "creation_id=17900000000000001" \
18  -d "access_token=$THREADS_TOKEN"
19# {"id":"17900000000000002"}   <- this is the published post id, not the container id

The id that comes back from step 3 is a different id from the container. Store that one — it is what insights, replies, hide and delete all key off.

What you can put in a container

Constraint

Value

media_type

TEXT, IMAGE, VIDEO for a single post; CAROUSEL for a carousel; IMAGE or VIDEO on carousel children

Text length

500 characters. Emoji count as their number of UTF-8 bytes

Links in the text

5 or fewer

Carousel children

At least 2, at most 20 images, videos, or a mix

Container lifetime

24 hours from creation, then the status turns EXPIRED

Wait before publishing

Meta recommends on average 30 seconds for media to process

Media hosting

A publicly reachable HTTPS URL. Threads fetches the bytes itself — there is no upload endpoint

Asset specs, from Meta's posts reference:


Images

Video

Formats

JPEG, PNG

MOV or MP4 (MPEG-4 Part 14)

Codecs

—

HEVC or H264 video; AAC audio, 48 kHz sample rate max

Max file size

8 MB

1 GB

Duration

—

300 seconds (5 minutes)

Aspect ratio

Up to 10:1

Between 0.01:1 and 10:1

Width

320–1440 px

1920 px max

Other

sRGB colour space

100 Mbps max bitrate, 23–60 FPS

A carousel means one container per child plus a parent container, so a 20-item carousel is 21 creates and 21 status polls before a single publish call. Budget wall-clock accordingly: if your publish runs inside one HTTP request, a per-container timeout multiplies into something far longer than that request will live. Give the whole publish one deadline, not each container its own.

Container status is the step people skip

GET /{container-id}?fields=status,error_message is the only way to know whether a container is publishable. Skipping it works fine for text and then falls over the first time someone attaches a video. The five states (troubleshooting):

status

Meaning

What to do

IN_PROGRESS

Still processing

Keep polling

FINISHED

Container and media are ready

Publish it

PUBLISHED

Already live

Stop. Publishing again double-posts

ERROR

Failed permanently

Terminal. Read error_message, fix the asset, build a new container

EXPIRED

Not published within 24 hours

Terminal. Build a new container

ERROR and EXPIRED are terminal and worth treating as such rather than polling them to exhaustion behind a generic timeout — a caller who gets "timed out" back will retry, and a dead container never gets better. The error_message field is the useful half of that failure and it names the actual problem: FAILED_DOWNLOADING_VIDEO, FAILED_PROCESSING_VIDEO, FAILED_PROCESSING_AUDIO, INVALID_ASPEC_RATIO (Meta's typo, not ours), INVALID_BIT_RATE, INVALID_DURATION, INVALID_FRAME_RATE, INVALID_AUDIO_CHANNELS, INVALID_AUDIO_CHANNEL_LAYOUT, UNKNOWN. Surface it verbatim to whoever submitted the post; "invalid frame rate" is something a user can act on, "publish failed" is not.

Meta recommends polling "once per minute, for no more than 5 minutes". That is a sensible ceiling and a poor default. Text and image containers are usually FINISHED within a second or two, so a flat minute makes the common case sixty times slower than it needs to be. Our publishing worker starts at one second and widens — 1s, 2s, 3s, 5s, 10s, 15s, then 30s — which keeps text instant and still gets a long video its five minutes.

One more thing worth building in: the first status read can legitimately 404 while a freshly-created container propagates. Treat a single failed read as noise and a few consecutive ones as fatal. Treating the first as fatal makes your publish path flaky for no reason.

Replies, reply chains and moderation

Replies are the reason to use Threads programmatically at all, and they are the best-designed part of the API.

A reply is an ordinary container with reply_to_id set. Every Threads post is itself replyable, so reply_to_id takes a reply's id exactly as happily as a root post's — and Threads keeps the nesting rather than flattening everything onto the top-level comment. A reply chain is therefore just: publish the root, then publish each subsequent container with reply_to_id pointing at the previous published id.

Reading a thread back uses GET /{media-id}/conversation, which hands you every reply at every depth in one flat list. The field that makes it reconstructable is replied_to: without it, a reply to a reply is indistinguishable from a reply to the post, and you cannot rebuild the tree at any price. Ask for it explicitly — fields=id,text,timestamp,username,is_reply,replied_to,root_post,has_replies,hide_status — and the nesting becomes a local regroup with no extra API calls.

Who can reply

reply_control on the container restricts the audience (reply management). It accepts everyone, accounts_you_follow, mentioned_only, parent_post_author_only and followers_only. It is set at container creation and immutable after the post is live. If your product lets users choose, collect the choice before you publish, because there is no second chance.

Hiding and approving

  • Hide: POST /{reply-id}/manage_reply with hide=true or false. Meta's docs are explicit that this "will automatically hide/unhide all the nested replies" — so one call removes a subtree, which is usually what you want and occasionally a surprise.
  • Approvals: set enable_reply_approvals=true when you create the container, then poll GET /{media-id}/pending_replies and resolve each with POST /{reply-id}/manage_pending_reply and approve=true|false. The reply_approval_status field comes back as pending or ignored.
  • Editing a reply's text is not possible. Hide it or delete it.

Outstand exposes all three of those as plain REST if you would rather not hold Threads media ids yourself: list pending replies, approve or ignore one, and hide or unhide a reply.

Rate limits and quotas

Threads publishes hard daily quotas rather than leaving you to guess (troubleshooting). All four run on a quota_duration of 86400 seconds:

Action

Quota per 24 hours

Usage field

Published posts

250

quota_usage

Replies

1,000

reply_quota_usage

Deletes

100

delete_quota_usage

Location searches

500

location_search_quota_usage

Read your own remaining budget from GET /{threads-user-id}/threads_publishing_limit rather than counting locally. Local counters drift the moment the same account posts from the Threads app, and the quota is per account, not per app.

General API calls are limited separately, on impressions: 4,800 calls per impression in a rolling 24-hour window (overview). For a publishing integration the 250-posts ceiling binds long before that one does. It matters if you poll insights aggressively for low-reach accounts — an account with almost no impressions has almost no read budget.

The failure modes worth writing code for

These are in rough order of how often you will meet them.

Symptom

Cause

What to do

OAuthException code 24, subcode 4279009, "The media with ID … cannot be found" on threads_publish

A create-to-publish propagation race. The container exists; the publish path has not seen it yet

Re-read the container status, then resend the same creation_id. The refusal alone does not prove nothing published

status: EXPIRED

Container older than 24 hours

Build a fresh container. Nothing to salvage

status: ERROR plus an error_message

The asset violates a spec

Terminal. Surface error_message to the user and fix the asset

First status read returns 404

Container still propagating

Tolerate a few consecutive failures, then give up. Never on the first

400 on create, no container

Text over 500 characters

Count UTF-8 bytes, not code points, if the text has emoji

Root post live, reply missing

A reply container failed after the root published

Record the per-reply failure and keep the root's id. A retry that republishes the root double-posts

401 after weeks of working

Long-lived token passed 60 days without a refresh

Re-authorize the user. There is no recovery from an expired token

The first row deserves a paragraph, because it is the one that will cost you a day. Meta returns that refusal with "is_transient": false, which reads as "do not retry". It is wrong — the container is fine, the publish path simply has not caught up. The subcode is not on Meta's troubleshooting page; we found it in production.

But retrying it blindly is also wrong, for two reasons: a container that has genuinely died will return the same error until your budget runs out, and a container that actually did publish will get published twice. The fix is to re-read the container's status between attempts. PUBLISHED means treat it as done and return. ERROR or EXPIRED means fail now with Meta's own message. Anything else means wait and try the same creation_id again.

Everything else — auth, rate limits, validation — should throw on the first attempt. Retrying a 400 just delays the moment somebody reads the error.

If you would rather not run the container dance

Everything above is what we implement so that you do not have to. What follows is the honest version, limits first.

What we do not solve

  • Editing a published post. Threads has no edit endpoint, so neither do we.
  • All five reply_control values. Our API accepts everyone, accounts_you_follow and mentioned_only — three of Meta's five.
  • App Review, if you need your own Meta app. Managed Keys means you use ours and skip it. Bring your own credentials and Meta's queue is Meta's queue.

What we do

One POST /v1/posts creates the container, polls it to FINISHED, publishes it, and chains a containers[] array as replies in order — with the whole publish under a single deadline and the subcode-4279009 retry already in it. Token refresh runs on our side, so you do not own the 60-day clock:

bash
1curl -s -X POST "https://api.outstand.so/v1/posts" \
2  -H "Authorization: Bearer $OUTSTAND_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "accounts": ["Kx7vQ"],
6    "containers": [
7      { "content": "Shipped Threads support today. Notes on the container flow below.",
8        "media": [{ "url": "https://cdn.example.com/ship-it.jpg" }] },
9      { "content": "The part that cost us a day: subcode 4279009 says is_transient false and means the opposite." }
10    ],
11    "threads": { "reply_control": "everyone", "countries": ["US", "GB"] },
12    "scheduledAt": "2026-10-02T09:00:00Z"
13  }'

The response hands back the post and its containers, with the root and each reply individually addressable:

json
1{
2  "success": true,
3  "post": {
4    "id": "9dyJS",
5    "orgId": "abc123",
6    "publishedAt": null,
7    "scheduledAt": "2026-10-02T09:00:00Z",
8    "isDraft": false,
9    "scheduled": true,
10    "socialAccounts": [
11      { "id": "Kx7vQ", "nickname": "Our brand", "network": "threads", "username": "your-brand" }
12    ],
13    "containers": [
14      { "id": "8xKmL", "content": "Shipped Threads support today. Notes on the container flow below.",
15        "media": [{ "id": 123, "url": "https://cdn.example.com/ship-it.jpg",
16                    "filename": "ship-it.jpg" }] },
17      { "id": "8xKmM", "content": "The part that cost us a day: subcode 4279009 says is_transient false and means the opposite.",
18        "media": [] }
19    ]
20  }
21}

Two details in there that are Threads-shaped rather than generic. threads.countries is geo-gating: pass ISO 3166-1 alpha-2 codes and the post is not shown to Threads profiles outside that list. And the 500-character ceiling is enforced at validation time, before anything is stored — including when you cross-post one body to Threads and LinkedIn together, where a threads.content override lets the long version go to LinkedIn and a short one to Threads. The full field list is in the create a post reference.

A word on Managed Keys, since it is the reason most people skip App Review: it is a shared app identity. Your accounts connect through Outstand's Meta app, which means you inherit that app's standing with Meta and the OAuth screen carries our brand rather than yours. That trade is a good one if you want to ship this week. If you need your own app-level identity, bring your own credentials — the setup guide covers both paths.

The details of our own posting model — scheduling, retries, what a publish failure looks like per account — are in the post lifecycle docs.

If you want the platform-specific version of all this, our Threads API page covers what we support on Threads specifically. If you are wiring Threads into an agent rather than an app, the Threads MCP server exposes the same publishing surface as tools. And if Threads is one of several platforms you need, that is what the rest of the social media api does.

The short version

The Threads API is genuinely good once you know its shape. Create a container, poll it, publish it, keep the published id. Respect 500 characters, 24-hour containers, 250 posts and 1,000 replies a day. Refresh the token before day 60. And when threads_publish tells you the media cannot be found, do not believe the is_transient: false — re-read the container and send the same creation_id again.

Those five things are most of the distance between a Threads integration that works in staging and one that works in production.