Threads API Posting Guide 2026
Meta's Threads API allows developers to create posts, upload media, manage replies, and build integrations with the Threads platform. This guide covers everything you need to know about posting to Threads programmatically in 2026, from initial authentication through publishing your first post and handling errors.
If you are building a social media tool, a scheduling app, or just want to automate your Threads presence, this tutorial walks through every step with working code examples.
Prerequisites
Before you can use the Threads API, you need:
- A Meta Developer account at developers.facebook.com
- A Threads profile linked to your Instagram account
- A registered Meta App with Threads API permissions enabled
- Your app configured with valid OAuth redirect URIs
The Threads API is part of Meta's Graph API ecosystem. You will interact with it using standard HTTP requests. The base URL for all Threads API calls is https://graph.threads.net/v1.0.
Understanding the OAuth Flow
Threads uses OAuth 2.0 for authentication. The flow involves redirecting users to Meta's authorization page, receiving an authorization code, then exchanging that code for an access token. Here is how each step works.
Step 1: Build the Authorization URL
Redirect the user to the Threads authorization endpoint with your app credentials:
const authUrl = 'https://threads.net/oauth/authorize'
+ '?client_id=YOUR_APP_ID'
+ '&redirect_uri=https://yourapp.com/callback'
+ '&scope=threads_basic,threads_content_publish,threads_manage_replies'
+ '&response_type=code'
+ '&state=random_csrf_token';
// Redirect the user to authUrl
The scope parameter determines what permissions your app requests. The key scopes are:
threads_basic— read the user's profile informationthreads_content_publish— create and publish posts on behalf of the userthreads_manage_replies— manage replies to the user's poststhreads_read_replies— read replies on the user's poststhreads_manage_insights— access post and profile analytics
Always include a state parameter with a random value to prevent CSRF attacks. Verify this value when the user is redirected back to your app.
Step 2: Handle the Callback
After the user authorizes your app, Meta redirects them to your redirect_uri with an authorization code:
// Your callback handler receives:
// GET /callback?code=AUTH_CODE&state=random_csrf_token
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify the state parameter matches what you sent
if (state !== expectedState) {
return res.status(403).send('Invalid state parameter');
}
// Exchange code for short-lived token
const tokenRes = await fetch('https://graph.threads.net/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: 'YOUR_APP_ID',
client_secret: 'YOUR_APP_SECRET',
grant_type: 'authorization_code',
redirect_uri: 'https://yourapp.com/callback',
code: code,
}),
});
const { access_token, user_id } = await tokenRes.json();
// Store access_token securely — it is short-lived (1 hour)
});
Step 3: Exchange for a Long-Lived Token
Short-lived tokens expire in 1 hour. For any production use, you need to exchange them for long-lived tokens that last 60 days:
const longLivedRes = await fetch(
'https://graph.threads.net/access_token'
+ '?grant_type=th_exchange_token'
+ '&client_secret=YOUR_APP_SECRET'
+ '&access_token=SHORT_LIVED_TOKEN'
);
const { access_token, expires_in } = await longLivedRes.json();
// expires_in is in seconds (typically 5184000 = 60 days)
// Store this token and set a reminder to refresh before expiry
To refresh a long-lived token before it expires, make a similar request with grant_type=th_refresh_token. You can only refresh tokens that are at least 24 hours old and have not yet expired.
const refreshRes = await fetch(
'https://graph.threads.net/refresh_access_token'
+ '?grant_type=th_refresh_token'
+ '&access_token=LONG_LIVED_TOKEN'
);
const { access_token: newToken, expires_in } = await refreshRes.json();
Creating and Publishing Posts
The Threads API uses a two-step publishing process: first you create a media container, then you publish that container. This pattern ensures media is processed before publishing and gives you a chance to verify the content.
Step 1: Create a Media Container
For a text-only post:
const createRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_type: 'TEXT',
text: 'Hello from the Threads API! This is my first automated post.',
}),
}
);
const { id: containerId } = await createRes.json();
// containerId is used in the next step to publish
Step 2: Publish the Container
const publishRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
creation_id: containerId,
}),
}
);
const { id: postId } = await publishRes.json();
console.log('Published post:', postId);
The two-step process might seem redundant for text posts, but it becomes essential when you are working with images or carousels, because media needs time to process on Meta's servers before it can be published.
Image Posts
To create a post with an image, you provide a publicly accessible URL for the image when creating the media container:
// Step 1: Create container with image
const createRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_type: 'IMAGE',
image_url: 'https://example.com/my-image.jpg',
text: 'Check out this image posted via the Threads API!',
}),
}
);
const { id: containerId } = await createRes.json();
// Step 2: Check container status before publishing
// Images need processing time
async function waitForProcessing(containerId) {
while (true) {
const statusRes = await fetch(
`https://graph.threads.net/v1.0/${containerId}?fields=status`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const { status } = await statusRes.json();
if (status === 'FINISHED') return true;
if (status === 'ERROR') throw new Error('Media processing failed');
// Wait 2 seconds before checking again
await new Promise(r => setTimeout(r, 2000));
}
}
await waitForProcessing(containerId);
// Step 3: Publish
const publishRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ creation_id: containerId }),
}
);
Important notes about images:
- The
image_urlmust be publicly accessible — Meta's servers fetch the image from your URL - Supported formats: JPEG, PNG, GIF, WebP
- Maximum file size: 8MB
- Images are processed asynchronously — always check the container status before publishing
- Video posts use
media_type: 'VIDEO'with avideo_urlfield and have a 500MB limit
Carousel Posts
Carousel posts allow you to include multiple images in a single post. The process involves creating individual media containers for each image, then combining them into a carousel container:
// Step 1: Create individual item containers
const imageUrls = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
'https://example.com/image3.jpg',
];
const itemIds = [];
for (const url of imageUrls) {
const res = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_type: 'IMAGE',
image_url: url,
is_carousel_item: true,
}),
}
);
const { id } = await res.json();
itemIds.push(id);
}
// Step 2: Create the carousel container
const carouselRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_type: 'CAROUSEL',
children: itemIds,
text: 'A carousel post with multiple images!',
}),
}
);
const { id: carouselId } = await carouselRes.json();
// Step 3: Wait for processing, then publish
await waitForProcessing(carouselId);
await fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ creation_id: carouselId }),
}
);
Carousels support between 2 and 10 items. Each item can be an image or a video. You cannot mix carousel items with a link attachment.
Reply Management
The Threads API lets you create replies to existing posts and manage reply controls. To reply to a post, set the reply_to_id field when creating your media container:
// Create a reply to an existing post
const replyRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_type: 'TEXT',
text: 'This is a reply to the original post.',
reply_to_id: originalPostId,
}),
}
);
const { id: replyContainerId } = await replyRes.json();
// Publish the reply
await fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ creation_id: replyContainerId }),
}
);
You can also control who can reply to your posts by setting the reply_control field when creating the initial media container:
// Options: 'everyone', 'accounts_you_follow', 'mentioned_only'
body: JSON.stringify({
media_type: 'TEXT',
text: 'Only my followers can reply to this post.',
reply_control: 'accounts_you_follow',
})
To fetch replies on your posts, use the conversations endpoint:
const repliesRes = await fetch(
`https://graph.threads.net/v1.0/${postId}/conversation`
+ '?fields=id,text,username,timestamp'
+ `&access_token=${accessToken}`
);
const { data: replies } = await repliesRes.json();
Link Attachments
When you include a URL in your post text, Threads automatically generates a link preview. There is no separate field for link attachments. Simply include the URL in the text parameter:
body: JSON.stringify({
media_type: 'TEXT',
text: 'Check out our latest blog post about social media automation https://example.com/blog/automation',
})
Threads will automatically extract the Open Graph metadata from the linked page to generate a preview card. Make sure your linked pages have proper og:title, og:description, and og:image tags.
Rate Limits
The Threads API enforces rate limits to prevent abuse. Understanding these limits is critical for any production application:
- Publishing limit: 250 posts per 24-hour period per user
- API call limit: 200 calls per user per hour for most endpoints
- Media container creation: subject to the same publishing limits
- Rate limit headers: check
x-app-usageandx-business-use-case-usageresponse headers
When you hit a rate limit, the API returns a 429 status code. Your application should implement exponential backoff:
async function postWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, options);
if (res.status === 429) {
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.log(`Rate limited. Waiting ${waitTime}ms before retry...`);
await new Promise(r => setTimeout(r, waitTime));
continue;
}
return res;
}
throw new Error('Max retries exceeded');
}
Common Errors and Troubleshooting
Here are the most frequent errors you will encounter when working with the Threads API and how to resolve them:
Error: "Invalid access token"
This means your token has expired or was revoked. Short-lived tokens expire after 1 hour. Long-lived tokens expire after 60 days. Always check expiration before making API calls and refresh proactively.
Error: "Media container not ready"
You tried to publish a container that has not finished processing. Always poll the container status endpoint and wait for FINISHED status before calling the publish endpoint. This is especially common with video content.
Error: "User has not authorized this permission"
Your app is trying to use a scope that the user did not grant. Re-check your authorization URL scopes and ensure the user completed the full OAuth flow. You may need to re-authorize with updated scopes.
Error: "Image URL is not accessible"
Meta's servers cannot fetch your image URL. Verify that the URL is publicly accessible (not behind authentication), uses HTTPS, and the server does not block Meta's crawlers. Test by opening the URL in an incognito browser window.
Error: "Rate limit exceeded"
You have exceeded the API rate limits. Implement exponential backoff and consider queuing posts to stay within the 250 posts per 24 hours limit. Check the x-app-usage header to monitor your usage.
Error: "Content policy violation"
The post content was flagged by Meta's content moderation. Review Meta's Community Standards and ensure your automated content complies. This can also happen with certain URLs or images that trigger automated filters.
Retrieving Post Insights
If your app has the threads_manage_insights scope, you can retrieve analytics for published posts:
const insightsRes = await fetch(
`https://graph.threads.net/v1.0/${postId}/insights`
+ '?metric=views,likes,replies,reposts,quotes'
+ `&access_token=${accessToken}`
);
const { data: metrics } = await insightsRes.json();
// Each metric includes name, period, values, and title
Available metrics include views, likes, replies, reposts, and quotes. Profile-level insights are also available, including followers_count and follower_demographics.
Best Practices for Production
- Store tokens securely. Never expose access tokens in client-side code or logs. Use encrypted storage and environment variables.
- Implement token refresh. Set up a scheduled job to refresh long-lived tokens at least a week before they expire.
- Queue posts. Instead of publishing immediately, use a job queue to space out API calls and respect rate limits.
- Handle webhook events. Meta provides webhooks for Threads that notify you of new replies and mentions. This is more efficient than polling.
- Monitor container status. Always check the media container status before publishing, especially for image and video content.
- Log API responses. Keep structured logs of all API calls and responses for debugging. Include request IDs from Meta's response headers.
- Test in sandbox mode. Use Meta's test users and sandbox environment before going live with real user accounts.
Complete Working Example
Here is a complete Node.js function that handles the full lifecycle of creating and publishing a Threads post with error handling:
const fetch = require('node-fetch');
async function publishToThreads({ userId, accessToken, text, imageUrl }) {
// Step 1: Create container
const containerBody = { media_type: imageUrl ? 'IMAGE' : 'TEXT', text };
if (imageUrl) containerBody.image_url = imageUrl;
const createRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(containerBody),
}
);
if (!createRes.ok) {
const err = await createRes.json();
throw new Error(`Container creation failed: ${err.error?.message}`);
}
const { id: containerId } = await createRes.json();
// Step 2: Wait for processing (if image)
if (imageUrl) {
let attempts = 0;
while (attempts < 30) {
const statusRes = await fetch(
`https://graph.threads.net/v1.0/${containerId}?fields=status`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const { status } = await statusRes.json();
if (status === 'FINISHED') break;
if (status === 'ERROR') throw new Error('Media processing failed');
await new Promise(r => setTimeout(r, 2000));
attempts++;
}
}
// Step 3: Publish
const publishRes = await fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ creation_id: containerId }),
}
);
if (!publishRes.ok) {
const err = await publishRes.json();
throw new Error(`Publishing failed: ${err.error?.message}`);
}
const { id: postId } = await publishRes.json();
return postId;
}
// Usage
publishToThreads({
userId: '12345',
accessToken: 'your-long-lived-token',
text: 'Automated post from my app!',
imageUrl: 'https://example.com/photo.jpg', // optional
}).then(id => console.log('Published:', id))
.catch(err => console.error(err));
Skip the API Complexity with Kleo
Building and maintaining a Threads API integration involves OAuth token management, media processing, rate limit handling, error recovery, and staying on top of API changes. If you are a founder or small team, that is time better spent on your actual product.
Kleo handles all of this for you. Connect your Threads account in one click, generate posts with AI that understands your brand, and schedule them across LinkedIn, X, Threads, and Bluesky from a single dashboard. No API keys, no token refreshing, no rate limit calculations.
Post to Threads without touching the API
Kleo handles OAuth, media uploads, rate limits, and scheduling. Just write and publish.
Get Started with Kleo