Threads API Auto Posting Tutorial 2026: From App Setup to Scheduled Posts (Node.js Code Included)
Table of Contents
- Three facts about the Threads API before you start
- Step 1: Create the Threads app
- Which scopes you need
- Step 2: Accept the Threads Tester invitation
- Step 3: OAuth and tokens
- 3a. Send the user to the authorization page
- 3b. Exchange the code for a short-lived token (1 hour)
- 3c. Immediately exchange for a 60-day long-lived token
- 3d. Refresh before expiry
- Step 4: Publish (two-step)
- Image posts
- Video posts: do not sleep, poll
- Step 5: Scheduling. The API has none, so you build it
- Common errors, quick lookup
- Don't want to build it?
Short answer first: auto posting with the Threads API takes five steps. ① Create an app with the Threads use case in the Meta developer dashboard. ② Have your Threads account accept the Threads Tester invite. ③ Run OAuth, get a 1-hour token, exchange it right away for a 60-day token. ④ Publishing is two-step: POST /{user-id}/threads creates a media container, POST /{user-id}/threads_publish publishes it. ⑤ The API has no scheduler, so you call it from cron or a scheduling tool. Each account gets 250 API-published posts per rolling 24 hours and text posts are capped at 500 characters.
Every code block below is lifted from the production code of MindThread, which uses this API to auto post for 100+ Threads accounts. Where we hit a wall, I say so.
If you do not want to write code and just want "schedule it and forget it", skip to the last section. This article is for people wiring up the API themselves.
Three facts about the Threads API before you start
1. It is Meta's official API at graph.threads.net. It is separate from the Instagram Graph API, with its own App ID and App Secret. When you create the app you will see two sets of IDs; use the Threads pair.
2. Publishing is two-step. You cannot publish in one request. First create a media container and get a creation_id, then publish that id. For text posts a short wait in between is enough; for video you must poll the container status until processing finishes.
3. The quota is a rolling 24-hour window. Official numbers: 250 API-published posts and 1,000 replies per Threads profile per rolling 24 hours. Text is limited to 500 characters. The quota is per account, not per app.
Step 1: Create the Threads app
- Go to developers.facebook.com and create a new app with the Threads use case.
- In the app dashboard, open the Threads settings and note the Threads App ID and Threads App Secret. Again: the Threads pair, not the Facebook pair.
- Set the Redirect URI for OAuth. For local development something like
https://localhost:3000/callbackis fine; production must be a real HTTPS URL.
Which scopes you need
For auto posting, two:
threads_basicreads your own profile and poststhreads_content_publishpublishes
Both are granted automatically, no App Review. You only need review for replies, reading comments, and insights (threads_manage_replies, threads_read_replies, threads_manage_insights, and so on) if you want them to work for arbitrary users.
MindThread keeps every scope it uses in one constant, annotated as the single source of truth for App Review. We once added a permission in code without adding it to the authorization URL; users authorized, and the new feature silently returned 401.
// auto-granted (no review): threads_basic, threads_content_publish
// everything else needs App Review
const SCOPES = 'threads_basic,threads_content_publish'
Step 2: Accept the Threads Tester invitation
This is where most people get stuck. While the app is in development mode, only accounts added as Threads Testers can use the API.
- App dashboard → App roles → Roles → Add People → pick Threads Tester, enter the Threads account.
- Log in to Threads with that account (web or mobile), go to Account Settings → Website permissions, and accept the pending invite.
Skip step 2 and the whole OAuth flow looks fine until the first publish call, which fails with a permission error.
Step 3: OAuth and tokens
3a. Send the user to the authorization page
https://www.threads.net/oauth/authorize
?client_id={THREADS_APP_ID}
&redirect_uri={REDIRECT_URI}
&scope=threads_basic,threads_content_publish
&response_type=code
&state={random string, CSRF protection}
Always send state and verify it on the way back. MindThread uses an HMAC-signed string and checks signature plus age. We originally set the age limit to 10 minutes; users who hesitated on the consent screen came back and got rejected as CSRF. It is 45 minutes now.
3b. Exchange the code for a short-lived token (1 hour)
const res = await fetch('https://graph.threads.net/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: THREADS_APP_ID,
client_secret: THREADS_APP_SECRET,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code,
}),
})
const { access_token: shortToken, user_id } = await res.json()
3c. Immediately exchange for a 60-day long-lived token
The short-lived token lives one hour. Exchange it on the spot; never store the short one.
const params = new URLSearchParams({
grant_type: 'th_exchange_token',
client_secret: THREADS_APP_SECRET,
access_token: shortToken,
})
const res = await fetch(`https://graph.threads.net/access_token?${params}`)
const { access_token: longToken, expires_in } = await res.json()
// expires_in is in seconds (about 60 days); store the expiry, you will need it
3d. Refresh before expiry
const params = new URLSearchParams({
grant_type: 'th_refresh_token',
access_token: currentLongToken,
})
const res = await fetch(`https://graph.threads.net/refresh_access_token?${params}`)
const { access_token: newToken, expires_in } = await res.json()
The number one reason auto posting stops is a token that was never refreshed. MindThread runs a daily job that refreshes every token expiring within 7 days, and writes a flag back to the database when a refresh fails. Otherwise you think the account is alive while it has quietly stopped posting.
Step 4: Publish (two-step)
This is MindThread's production text-post function, nearly verbatim:
const THREADS_API = 'https://graph.threads.net/v1.0'
async function publishTextPost(threadsUserId: string, accessToken: string, text: string) {
// Step A: create the media container (a draft)
const createRes = await fetch(`${THREADS_API}/${threadsUserId}/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
media_type: 'TEXT',
text,
access_token: accessToken,
}),
})
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({}))
throw new Error(`container create failed ${createRes.status}: ${err?.error?.message}`)
}
const { id: creationId } = await createRes.json()
// give the server a moment to process the container (3s is enough for text)
await new Promise(r => setTimeout(r, 3000))
// Step B: publish
const publishRes = await fetch(`${THREADS_API}/${threadsUserId}/threads_publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
creation_id: creationId,
access_token: accessToken,
}),
})
if (!publishRes.ok) {
const err = await publishRes.json().catch(() => ({}))
throw new Error(`publish failed ${publishRes.status}: ${err?.error?.message}`)
}
return publishRes.json() // { id: post id }
}
Details that matter:
threadsUserIdis theuser_idfrom step 3b;mealso works.- Both requests are
application/x-www-form-urlencoded, not JSON. - The useful error text is in
error.message. Always log it; Meta's error codes alone tell you little.
Image posts
Set media_type to IMAGE and add image_url (a publicly reachable HTTPS URL). Everything else is the same; a 3-second wait before publish is fine.
body: new URLSearchParams({
media_type: 'IMAGE',
image_url: imageUrl,
text,
access_token: accessToken,
})
Video posts: do not sleep, poll
Video needs server-side transcoding and the time varies. A fixed 3-second sleep fails intermittently; we learned that the hard way. Poll the container until it reports FINISHED:
async function waitForContainerReady(creationId: string, accessToken: string) {
const deadline = Date.now() + 120_000
await new Promise(r => setTimeout(r, 5000))
while (Date.now() < deadline) {
const res = await fetch(
`${THREADS_API}/${creationId}?fields=status,error_message&access_token=${encodeURIComponent(accessToken)}`
)
const { status, error_message } = await res.json()
if (status === 'FINISHED') return
if (status === 'ERROR' || status === 'EXPIRED') {
throw new Error(`video container ${status}: ${error_message || 'no detail'}`)
}
await new Promise(r => setTimeout(r, 3000)) // IN_PROGRESS, keep waiting
}
throw new Error('video container not FINISHED within 120s')
}
Container status is one of IN_PROGRESS, FINISHED, ERROR, EXPIRED, PUBLISHED. Create the container with media_type: 'VIDEO' and video_url.
Step 5: Scheduling. The API has none, so you build it
The Threads API has no "publish at this time" feature. The official docs only remind you to enforce the publishing quota yourself "if your app allows users to schedule posts". So scheduling is your job:
| Approach | Good for | Watch out for |
|---|---|---|
| System cron / systemd timer | One or two accounts of your own | The machine must stay on; schedule token refresh too |
| Vercel Cron / Cloudflare Cron Triggers | People already on serverless | Function time limits; video polling can exceed them |
| n8n / Make | Non-coders who can build flows | You still own dedup and token refresh |
| A product like MindThread | Many accounts, needs to be reliable | Scheduling, dedup, refresh, and retries are built in |
Whichever you pick, three things are non-negotiable:
- Record what you already published. A scheduler that reruns or retries without dedup will double post, and Threads does not treat that kindly.
- Count your own daily posts instead of waiting for the API to reject you.
- Split failures into two classes. Timeouts and 5xx are transient and can be retried with backoff. Permission and token errors are permanent: stop and alert a human, retrying only repeats the error.
Common errors, quick lookup
| Symptom | Usually |
|---|---|
| OAuth works, publish says no permission | The Threads account never accepted the tester invite |
| Everything fails after one hour | You stored the short-lived token instead of exchanging it |
| Failures around day 60 | Long-lived token was never refreshed |
| Video posts fail intermittently, text is fine | Fixed sleep instead of polling container status |
| Image posts fail | image_url is not public HTTPS, or the file is too large / wrong format |
| Everything fails mid-day | You hit the 250-post rolling quota |
Don't want to build it?
All of the above is already a product: MindThread connects your Threads accounts, lets you set time slots, and handles scheduling, dedup, token renewal, retries, and multi-account management, with a free tier to start. For the no-code path see Best Threads Auto Posting Tools in 2026.
If what you actually want is automated engagement (commenting and liking on other people's posts), that is not something the Threads API does: replies need reviewed permissions and the API does not expose the feed. See Threads automation guide for what is and is not possible.
Code in this article comes from MindThread's production codebase and is current as of August 2026. We update this page when Meta changes the API; for quotas and permission names, the official Threads API documentation is authoritative.
FAQ
Is the Threads API free to use?
Yes. The API itself has no fee. Create an app with the Threads use case in the Meta developer dashboard and you can start. Your own account works through the Threads Tester flow without App Review. The limits are 250 API-published posts and 1,000 replies per account per rolling 24 hours.
Does the Threads API support scheduled posts?
No. The API only publishes now. Scheduling is on you: run a cron job, n8n, or Vercel Cron that calls the publish endpoints at the right time, and keep a record of what you already posted so retries do not duplicate. Tools like MindThread already bundle scheduling, dedup, and token renewal.
How long does a Threads access token last?
The short-lived token from OAuth lasts 1 hour. Exchange it immediately with th_exchange_token for a 60-day long-lived token, then refresh with th_refresh_token before it expires. Forgetting the refresh is the most common reason auto posting silently stops.
Why does the Threads API say I do not have permission?
Most often the Threads account has not accepted the tester invitation. After you add the account as a Threads Tester in the app dashboard, log in to Threads with that account and accept the invite under Account Settings, Website permissions.