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 |
|
|
Reply to a post or to another reply | Same two calls, with |
|
Repost |
|
|
Quote post | Container with |
|
Read a whole conversation |
|
|
Hide or unhide a reply |
|
|
List replies awaiting approval |
|
|
Approve or ignore a pending reply |
|
|
Post insights |
|
|
Account insights and follower demographics |
|
|
Delete a post or a reply |
|
|
Remaining publishing quota |
|
|
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
conversationedge 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:
- Authorize. Send the user to
https://threads.com/oauth/authorizewithclient_id,redirect_uri,scopeandresponse_type=code.threads_basicis mandatory in every scope list (getting access tokens). - Exchange the code.
POST /oauth/access_tokenwithgrant_type=authorization_code. Authorization codes are valid for one hour. - 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). - 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 |
|---|---|
| Required for every Threads endpoint |
| The publishing endpoints only |
|
|
|
|
|
|
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:
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 idThe 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 |
|---|---|
|
|
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 |
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):
| Meaning | What to do |
|---|---|---|
| Still processing | Keep polling |
| Container and media are ready | Publish it |
| Already live | Stop. Publishing again double-posts |
| Failed permanently | Terminal. Read |
| 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_replywithhide=trueorfalse. 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=truewhen you create the container, then pollGET /{media-id}/pending_repliesand resolve each withPOST /{reply-id}/manage_pending_replyandapprove=true|false. Thereply_approval_statusfield comes back aspendingorignored. - 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 |
|
Replies | 1,000 |
|
Deletes | 100 |
|
Location searches | 500 |
|
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 |
|---|---|---|
| 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 |
| Container older than 24 hours | Build a fresh container. Nothing to salvage |
| The asset violates a spec | Terminal. Surface |
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_controlvalues. Our API acceptseveryone,accounts_you_followandmentioned_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:
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:
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.