Everything you need to build on the Threads API: getting access, OAuth tokens, publishing text, images, video and carousels, pulling post and account insights, and staying inside the rate limits - with copy-ready code in cURL, Python and Node.js.
$ curl -X POST graph.threads.net/v1.0/me/threads \
-d media_type=TEXT -d text="Hello, Threads 👋"
{ "id": "17890012345" }
$ curl -X POST …/me/threads_publish -d creation_id=17890012345
{ "id": "18024689753" }
✓ Published to Threads
The Threads API is Meta's official, free REST API for Threads. It's part of the Graph API family, served from graph.threads.net (also graph.threads.com) at version v1.0, and secured with OAuth 2.0. Here's what you can build with it.
Text, images, videos and carousels of 2–20 items, with topic tags, link attachments, quotes and reply controls.
Views, likes, replies, reposts, quotes and shares per post; followers, demographics and link clicks per account.
Read conversations, reply, hide replies, and track public mentions of a profile.
Search public posts by keyword or topic tag - the basis for social listening tools.
Read a user's profile fields and their published posts, with paging.
Get notified on your server when events happen, instead of polling.
You can build and test against your own account the same day. Accessing other people's accounts in production needs Meta's App Review.
At developers.facebook.com, create an app and add the Access the Threads API use case.
Add the scopes you need - at minimum threads_basic, plus e.g. threads_content_publish to post.
Register your OAuth callback URL. Invite your own Threads account as a tester and accept the invite in the Threads app's account settings (Website permissions).
With testers, every endpoint works on their accounts - no review needed. Use the Graph API Explorer to try calls quickly.
To let any Threads user connect, request advanced access for each permission with a screencast and a description of how you use it.
| Permission | What it unlocks |
|---|---|
| threads_basic | Required for every call - profile and posts |
| threads_content_publish | Create and publish posts |
| threads_read_replies | Read replies and conversations |
| threads_manage_replies | Reply, hide replies, set reply controls |
| threads_manage_insights | Post and account insights |
| threads_delete | Delete posts |
| threads_keyword_search | Search public posts by keyword or tag |
| threads_manage_mentions | Read posts that mention the profile |
| threads_location_tagging | Search and tag locations |
| threads_share_to_instagram | Cross-share posts to Instagram Stories |
Users authorize your app, you swap the code for a short-lived token (1 hour), exchange that for a long-lived token (60 days), and refresh it before it expires.
https://threads.net/oauth/authorize ?client_id=YOUR_APP_ID &redirect_uri=https://yourapp.com/callback &scope=threads_basic,threads_content_publish,threads_manage_insights &response_type=code &state=RANDOM_STRING
curl -X POST https://graph.threads.net/oauth/access_token \
-d client_id=YOUR_APP_ID \
-d client_secret=YOUR_APP_SECRET \
-d grant_type=authorization_code \
-d redirect_uri=https://yourapp.com/callback \
-d code=CODE_FROM_REDIRECT
# → { "access_token": "...", "user_id": 1234567890 } (valid 1 hour)
curl -G https://graph.threads.net/access_token \
-d grant_type=th_exchange_token \
-d client_secret=YOUR_APP_SECRET \
-d access_token=SHORT_LIVED_TOKEN
# → { "access_token": "...", "token_type": "bearer",
# "expires_in": 5183944 } (≈ 60 days)
curl -G https://graph.threads.net/refresh_access_token \ -d grant_type=th_refresh_token \ -d access_token=LONG_LIVED_TOKEN # Works once the token is 24h+ old and not yet expired. # Returns a new token valid for another 60 days.
Keep your app secret and tokens server-side, in environment variables or a secrets manager - never in browser code or a public repo. A long-lived token can be refreshed once it's at least 24 hours old; an expired token can't be refreshed, so schedule refreshes well before day 60.
Publishing is always two steps: create a media container, then publish it. For images and video, check the container's status before publishing - Meta recommends waiting about 30 seconds on average for processing.
# 1) Create a media container
curl -X POST "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads" \
-d media_type=TEXT \
-d text="Shipping something new today 🚀" \
-d access_token=$THREADS_TOKEN
# → { "id": "CONTAINER_ID" }
# 2) (Images/video) check the container is ready
curl -G "https://graph.threads.net/v1.0/CONTAINER_ID" \
-d fields=status,error_message \
-d access_token=$THREADS_TOKEN
# → { "status": "FINISHED", "id": "CONTAINER_ID" }
# 3) Publish it
curl -X POST "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads_publish" \
-d creation_id=CONTAINER_ID \
-d access_token=$THREADS_TOKEN
# → { "id": "POST_ID" }
import os, time, requests
BASE = "https://graph.threads.net/v1.0"
TOKEN = os.environ["THREADS_TOKEN"]
USER_ID = os.environ["THREADS_USER_ID"]
def wait_until_ready(container_id, tries=10):
for _ in range(tries):
r = requests.get(f"{BASE}/{container_id}",
params={"fields": "status,error_message",
"access_token": TOKEN}).json()
if r.get("status") == "FINISHED":
return
if r.get("status") in ("ERROR", "EXPIRED"):
raise RuntimeError(r)
time.sleep(5)
raise TimeoutError("Container not ready")
def publish(media_type="TEXT", **fields):
c = requests.post(f"{BASE}/{USER_ID}/threads",
data={"media_type": media_type,
"access_token": TOKEN, **fields})
c.raise_for_status()
container_id = c.json()["id"]
if media_type != "TEXT":
wait_until_ready(container_id)
p = requests.post(f"{BASE}/{USER_ID}/threads_publish",
data={"creation_id": container_id,
"access_token": TOKEN})
p.raise_for_status()
return p.json()["id"]
print(publish(text="Hello from Python 🐍"))
print(publish("IMAGE", image_url="https://example.com/photo.jpg",
text="New drop"))
// Node.js 18+ (built-in fetch)
const BASE = "https://graph.threads.net/v1.0";
const { THREADS_TOKEN: TOKEN, THREADS_USER_ID: USER_ID } = process.env;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(path, params = {}, method = "GET") {
const body = new URLSearchParams({ ...params, access_token: TOKEN });
const url = method === "GET" ? `${BASE}/${path}?${body}` : `${BASE}/${path}`;
const res = await fetch(url, method === "GET" ? {} : { method, body });
const json = await res.json();
if (!res.ok) throw new Error(JSON.stringify(json.error));
return json;
}
async function publish(fields) {
const { id } = await call(`${USER_ID}/threads`, fields, "POST");
if (fields.media_type !== "TEXT") {
for (let i = 0; i < 10; i++) {
const { status } = await call(id, { fields: "status,error_message" });
if (status === "FINISHED") break;
if (status === "ERROR" || status === "EXPIRED") throw new Error(status);
await sleep(5000);
}
}
const post = await call(`${USER_ID}/threads_publish`, { creation_id: id }, "POST");
return post.id;
}
publish({ media_type: "TEXT", text: "Hello from Node 👋" }).then(console.log);
| Media type | Key parameters | Limits |
|---|---|---|
| TEXT | text | Up to 500 characters |
| IMAGE | image_url (public URL) | JPEG/PNG, up to 8 MB, 320–1440 px wide |
| VIDEO | video_url (public URL) | MP4/MOV, up to 5 min and 1 GB, 23–60 fps |
| CAROUSEL | children (container IDs) | 2–20 image/video items |
| Optional parameter | Use it to |
|---|---|
| topic_tag | Add one topic tag (1–50 chars, no periods or "&") |
| link_attachment | Attach a link preview to a text post |
| reply_control | Limit who can reply (e.g. accounts you follow) |
| reply_to_id | Publish as a reply to another post |
| quote_post_id | Publish as a quote of another post |
For a carousel, create each item as a container with is_carousel_item=true, then create a container with media_type=CAROUSEL and children set to the item IDs, then publish that. Media URLs must be publicly reachable - Meta's servers fetch them.
With the threads_manage_insights permission you can read metrics for individual posts and for the whole profile - the same data that powers analytics dashboards.
| Level | Metrics |
|---|---|
Post/{media-id}/insights | views, likes, replies, reposts, quotes, shares |
Account/{user-id}/threads_insights | views (time series), likes, replies, reposts, quotes, clicks, followers_count, follower_demographics |
since / until (Unix timestamps) for date ranges; the default is the last two days.followers_count and demographics don't support date ranges.breakdown=country, city, age or gender.GET /{MEDIA_ID}/insights?metric=views,likes,replies,reposts
Bars not to scale - illustrative values.
curl -G "https://graph.threads.net/v1.0/MEDIA_ID/insights" \ -d metric=views,likes,replies,reposts,quotes,shares \ -d access_token=$THREADS_TOKEN
# Account totals for a date range (Unix timestamps) curl -G "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads_insights" \ -d metric=views,likes,replies,reposts,quotes,clicks \ -d since=1756684800 -d until=1759276800 \ -d access_token=$THREADS_TOKEN # Follower demographics (needs 100+ followers) curl -G "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads_insights" \ -d metric=follower_demographics -d breakdown=country \ -d access_token=$THREADS_TOKEN
{
"data": [
{
"name": "views",
"period": "lifetime",
"values": [ { "value": 12480 } ],
"title": "Views",
"id": "MEDIA_ID/insights/views/lifetime"
},
{
"name": "likes",
"period": "lifetime",
"values": [ { "value": 642 } ],
"title": "Likes",
"id": "MEDIA_ID/insights/likes/lifetime"
}
]
}
Read a post's replies or full conversation, publish replies with reply_to_id, and hide or unhide replies - the building blocks of moderation and community tools.
GET /keyword_search?q=… finds public posts by keyword, or by topic tag with search_mode=TAG. Sort with search_type=TOP or RECENT. Limited to 500 queries per rolling 7 days.
Fetch public posts that mention a profile, and subscribe to webhooks so your server hears about new activity in real time instead of polling.
The API is free, but each profile has rolling quotas. Check usage before batch jobs with the publishing-limit endpoint.
Example usage for one profile. Quotas reset on a rolling basis.
curl -G "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads_publishing_limit" \
-d fields=quota_usage,config,reply_quota_usage,reply_config \
-d access_token=$THREADS_TOKEN
# → { "data": [ {
# "quota_usage": 37,
# "config": { "quota_total": 250, "quota_duration": 86400 },
# "reply_quota_usage": 212,
# "reply_config": { "quota_total": 1000, "quota_duration": 86400 }
# } ] }
Hit an error? See our Threads API errors & rate limits guide for codes and fixes.