Threads Posting API: Containers, Scopes and the 250-Post Limit
Threads runs on its own Meta host, its own scopes and its own v1.0 track, so Instagram publishing code ported across still type-checks and posts nothing. Covers the two-step container flow, all five container states, recovering from a 500 that already published, and the four separate quota buckets.
The Threads posting API publishes in two calls, against a host that is not the one Instagram uses: POST https://graph.threads.net/v1.0/{threads-user-id}/threads creates a container, then POST .../threads_publish makes it live. Different host, different scopes, different version track, different field names. That last one is why Instagram publishing code ported straight across still compiles, still runs, and posts nothing. All numbers below were checked against Meta's Threads documentation on 4 August 2026.

Threads is not the Instagram Graph API
Meta runs three publishing surfaces, not two. Threads is the third, and it shares almost nothing with the other two except the shape of the flow.
| Threads | ||
|---|---|---|
| Authorization host | www.instagram.com/oauth/authorize or www.facebook.com/…/dialog/oauth | threads.net/oauth/authorize |
| API host | graph.instagram.com or graph.facebook.com | graph.threads.net |
| Path version | Graph version in the path (v23.0 in our Facebook Login client) | v1.0, and the OAuth endpoints carry no version at all |
| Scopes | instagram_business_* or instagram_* | threads_basic, threads_content_publish, … |
| Container endpoint | POST /{ig-user-id}/media | POST /{threads-user-id}/threads |
| Publish endpoint | POST /{ig-user-id}/media_publish | POST /{threads-user-id}/threads_publish |
| Text field | caption | text |
| Quota endpoint | GET /{id}/content_publishing_limit | GET /{id}/threads_publishing_limit |
Three of those rows quietly break ports. A Threads token is not an Instagram token even when it belongs to the same human. v1.0 is not a typo: Threads sits on its own version track, which has not moved while Graph went through more than twenty releases. And the text field has a different name.
But the field name is the one that gets past code review. In our own codebase Instagram and Threads share a single container type, with the two text fields eleven lines apart: caption for Instagram, text for Threads. A builder copied from the Instagram posting API path type-checks perfectly, sends caption to graph.threads.net, and Threads ignores it. On an image post that means a live post with no text; on media_type=TEXT it means a 400, because text is required there. Two symptoms, one cause, and neither error message says the word caption.
Getting a token: scopes and the 60-day clock
Four steps, and only the first is on threads.net.
- Send the user to
https://threads.net/oauth/authorize. The code that comes back is valid one hour, single use. - Exchange it at
POST https://graph.threads.net/oauth/access_tokenfor a short-lived token, valid one hour. - Exchange that at
GET /access_token?grant_type=th_exchange_tokenfor a long-lived token, valid 60 days. - Refresh at
GET /refresh_access_token?grant_type=th_refresh_tokento reset the 60 days.
Two rules on step 4 decide your refresh scheduler, both stated on Meta's long-lived tokens page. A token can only be refreshed once it is at least 24 hours old, and a token not refreshed within 60 days is gone: no grace period, no way back, the user reconnects. Anything looser than a monthly sweep drops accounts; the pattern is in OAuth token refresh for social APIs.
We request six scopes: threads_basic, threads_content_publish, threads_manage_insights, threads_manage_replies, threads_read_replies and threads_share_to_instagram. The authorization window reference still lists five under scope: threads_share_to_instagram arrived with the 25 March 2026 changelog entry and never reached that table. Publishing needs threads_content_publish, which needs advanced access: until App Review grants it you can post only to your own account and your app's testers, so the integration works all through development and fails on the first customer.
One more thing to handle early. The token endpoint returns a flat error, with error_type, code and error_message at the top level, not the {"error": {...}} envelope every Graph handler is written against. A generic Meta parser reads data.error, gets undefined, and reports an unknown failure instead of "Matching code was not found or was already used".
The two-step publish, end to end
Create the container. media_type is required and takes TEXT, IMAGE, VIDEO or CAROUSEL.
curl -i -X POST \ -d "media_type=IMAGE" \ -d "image_url=https://cdn.example.com/photo.jpg" \ -d "text=Morning shift" \ -d "access_token=${TOKEN}" \ "https://graph.threads.net/v1.0/${THREADS_USER_ID}/threads"
{ "id": "17889615691921648" }
That id is a container. Nothing is public yet. Meta cURLs your media from the URL you supplied, so the file has to stay reachable until processing finishes; deleting it right after this call breaks the fetch halfway. Meta recommends waiting on average 30 seconds before publishing, to give its servers time to finish the upload, and says so in the posts documentation next to the publish call.
curl -i -X POST \ -d "creation_id=17889615691921648" \ -d "access_token=${TOKEN}" \ "https://graph.threads.net/v1.0/${THREADS_USER_ID}/threads_publish"
Carousels are three steps, not two: create each child with is_carousel_item=true, create a parent with media_type=CAROUSEL and a children list, then publish the parent. Post-level options belong on the parent or the single container, never on the children.
There is also auto_publish_text, which publishes a text container at creation time and skips the second call entirely. It works for text posts only. We do not use it: our publish path is uniform across media types, and the recovery logic below depends on holding the container id before anything goes live.

Container states and the 24-hour window
curl -s -X GET \ "https://graph.threads.net/v1.0/${CONTAINER_ID}?fields=status,error_message&access_token=${TOKEN}"
Five states, and the fifth is the one that saves you money. The state list and the error messages behind it are in Meta's Threads troubleshooting guide.
status | Meaning | What to do |
|---|---|---|
IN_PROGRESS | Still uploading or processing | Poll again |
FINISHED | Ready to publish | Publish now |
ERROR | Processing failed; error_message says why | Terminal. Fix the input, build a new container |
EXPIRED | Not published within 24 hours | Terminal. Nothing to recover, re-upload |
PUBLISHED | Already live | Stop. Do not publish again |
On ERROR, error_message is one of ten values: FAILED_DOWNLOADING_VIDEO, FAILED_PROCESSING_AUDIO, FAILED_PROCESSING_VIDEO, INVALID_ASPEC_RATIO (Meta's typo, not ours), INVALID_BIT_RATE, INVALID_DURATION, INVALID_FRAME_RATE, INVALID_AUDIO_CHANNELS, INVALID_AUDIO_CHANNEL_LAYOUT and UNKNOWN. Surface the raw string: half of them tell the customer exactly which file to re-encode.
Meta recommends polling once per minute for no more than five minutes, which is the safe default. Ours is denser and tuned to our own traffic: a durable workflow sleeping 15 s, 15 s, 20 s against a ten-minute budget, because most images finish on the first tick and a full minute of latency per post is expensive at volume.
Now PUBLISHED, and the failure mode nobody writes about. Meta can publish a container and still hand you a 500. The post is live, your code sees an error, your retry posts it again. The customer gets duplicates and the quota is charged twice.
The fix has two halves, and the first one is boring:
// 1. Persist the container id BEFORE publishing, best-effort. const creationId = await buildContainer(account, post, uploads); await setPartialExternalData(post.id, "THREADS", { creationId }); // 2. On any publish failure, ask the container what actually happened. // PUBLISHED is authoritative, treat it as success, not as an error. try { return await publishMediaContainer(token, accountId, { creation_id: creationId }); } catch (error) { if (await isContainerPublished(token, creationId)) return { id: creationId }; throw error; }
Without the write in step 1 the next attempt has no id to ask about, so the check in step 2 never runs. And a failed status lookup must count as "unknown", never as "published". Guessing optimistically turns a real failure into a silently dropped post. A regression test pins all three paths: 500 plus PUBLISHED recovers, 400 plus ERROR fails, failed lookup fails.
While you are in there, classify the errors. Subcode 4279013, "Threads account restricted", is permanently non-retryable in our error map: no retry budget outlives it, and parking the post in a retrying state only delays the message the customer needs now. More on that in social media API error handling.
Fields Instagram does not have
The Threads container accepts a set of parameters with no Instagram equivalent. Several only work on text-only posts, which is the constraint most integrations discover from a 400.
| Parameter | Values / limits | Media posts? |
|---|---|---|
reply_control | everyone, accounts_you_follow, mentioned_only, parent_post_author_only, followers_only | Yes |
reply_to_id | Container becomes a reply to that post | Yes |
topic_tag | One per post, 1–50 chars, no . or & | Yes |
quote_post_id | Id of the post being quoted | Yes |
alt_text | Max 1,000 characters | Media only |
allowlisted_country_codes | ISO 3166-1 alpha-2 list; post hidden elsewhere (geo-gating) | Yes |
crossreshare_to_ig / _dark_mode | Cross-shares as an Instagram Story; needs threads_share_to_instagram (share to IG Stories) | Yes |
link_attachment | Preview card. Max 5 unique links per post (text attachments) | Text only |
poll_attachment | 2–4 options, 1–25 chars each (polls) | Text only |
gif_attachment | gif_id + provider; GIPHY is the only provider | Text only |
Since 22 December 2025 a post with more than five unique links fails at container creation with THREADS_API__LINK_LIMIT_EXCEEDED. The count includes URLs in text, plus link_attachment if it differs from all of them. We enforce the text-only rules in our own schema before the call goes out: a poll, a GIF or a link attachment alongside an upload is rejected locally, and a poll plus a GIF on the same post is rejected too.
bundle.social
One call instead of a container, a poll loop and 500 recovery.
Container, 30-second wait, poll loop and the 500-that-already-published, handled behind one call.

Reading your own publishing limit
Threads exposes the number that Instagram makes you guess at. One call returns all four buckets:
curl -s -X GET \ "https://graph.threads.net/v1.0/${THREADS_USER_ID}/threads_publishing_limit?fields=quota_usage,config,reply_quota_usage,reply_config,delete_quota_usage,delete_config,location_search_quota_usage,location_search_config&access_token=${TOKEN}"
| Bucket | quota_total | quota_duration | Extra permission |
|---|---|---|---|
Posts (quota_usage / config) | 250 | 86400 | threads_content_publish |
Replies (reply_quota_usage) | 1,000 | 86400 | threads_manage_replies |
Deletions (delete_quota_usage) | 100 | 86400 | threads_delete |
Location searches (location_search_quota_usage) | 500 | 86400 | n/a |
Four independent counters, all on a rolling 24-hour window, all reported as quota_duration: 86400 seconds, all four documented on Meta's rate limiting page. They do not borrow from each other: a bot that burns 1,000 replies still has its full 250 posts. The post limit is enforced on threads_publish, and a carousel counts as one post regardless of how many children it has.
On top of the per-profile quota sits a per-app one: 4800 × number of impressions calls per rolling 24 hours, where impressions is how often the account's content reached a screen, with a floor of 10.
If you schedule, read this endpoint before you queue, not after you fail. Meta asks for exactly that on the same page: it recommends your app enforce the publishing limit itself, "especially if your app allows app users to schedule posts to be published in the future". Queuing a hundred 03:00 posts against a bucket with three left is a support ticket you wrote yourself.
Media limits worth pre-checking
Numbers below are Meta's, from the media specifications in the posts documentation, checked 4 August 2026. Where we are stricter, it says so.
- Text: 500 characters, but emoji count as UTF-8 bytes. A four-byte emoji spends four of your 500. Fix this in your own validator first: ours is a plain
z.string().max(500), which measures JS string length, so an emoji-heavy post passes locally and is rejected by Threads. - Images: JPEG or PNG, 8 MB, width 320–1440 (scaled to fit), sRGB. Meta's aspect ratio limit is 10:1; our validator rejects anything past 1.91:1, our number, not the platform's.
- Video: MP4 or MOV, 1 GB, 300 seconds, VBR up to 100 Mbps, 23–60 FPS, max 1920 horizontal pixels, aspect ratio 0.01:1 to 10:1.
- Carousels: Meta documents 2–20 children. Our schema caps uploads at 10, again ours, not Meta's.
Full per-platform table in social media API media requirements.
One call instead of four
Publishing one Threads post properly is four HTTP calls, a poll loop, a persisted container id, and a status check that runs only when something has already gone wrong. Multiply by carousels, by token refresh on a 60-day clock, and by whichever other platforms your product promises.
The Threads API at bundle.social is one call. You send text, media and the Threads-specific options (reply_control, topic_tag, polls, link attachments), and the container flow, polling cadence, 24-hour expiry, quota check and "Meta 500'd after publishing" recovery all happen behind it. Tokens refresh on schedule; you get a webhook when the post is live, or a classified error naming the step that failed.
It is not the right tool if you need one platform and full control of every parameter. Talking to graph.threads.net directly is not hard; it is four calls that all have to go right, every time, on every account.
Frequently asked questions
Do I need an Instagram account to use the Threads API?
No, and most guides still say otherwise. Meta's changelog entry of 23 September 2025 opened the API to profiles with no linked Instagram account, minus two insights metrics. The entry of 30 January 2026 removed that exception, so followers_count and follower_demographics work too. Any page telling you to link Instagram first is out of date.
Can I publish a text-only post without media?
Yes. Set media_type=TEXT and pass text, which is required for that type. Text posts are also the only ones that accept a poll, a GIF or a link_attachment. To skip the publish call entirely, auto_publish_text publishes the container at creation, text posts only.
How many characters can a Threads post have?
500, but emoji count as their number of UTF-8 bytes rather than one character each, so a four-byte emoji costs four. A JavaScript string length check will pass posts that Threads rejects. Count bytes, or leave a margin below 500 on emoji-heavy content.
Is the Threads API free?
Meta's Threads documentation has no pricing or billing section: access is gated by App Review, not payment. What constrains you is quota and permissions: 250 published posts per rolling 24 hours per profile, plus advanced access for threads_content_publish before you can post for anyone but yourself and your testers.
Can I schedule posts through the Threads API?
Not natively. There is no publish_at parameter, so scheduling is yours to build, and two Threads specifics shape it: a container expires 24 hours after creation, so building them far in advance does not work, and Meta asks scheduling apps to enforce the 250-post limit themselves.