Every Threads API call needs an access token. This guide explains the token types and how long they last, how to get one through OAuth, how to turn it into a 60-day token and keep refreshing it, how to store it safely, and how to fix token errors.
An access token is a string your app sends with every request to prove a specific Threads user has granted it permission. It's tied to one user, one app and the permissions that user approved. There are two kinds you'll work with.
GET /access_token with grant_type=th_exchange_tokenThere are two routes: a quick token for testing on your own account, and the full OAuth flow for real users.
💡 Ideal for prototypes and scripts that only post to your own account. Exchange it for a long-lived token the same way as below.
state.code.state, then exchanges the code for a short-lived token and user ID.💡 Authorization codes are single-use and expire quickly - exchange them right away.
# pip install flask requests
import os, secrets, time, requests
from urllib.parse import urlencode
from flask import Flask, redirect, request, session, abort
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
APP_ID = os.environ["THREADS_APP_ID"]
APP_SECRET = os.environ["THREADS_APP_SECRET"]
REDIRECT_URI = "https://yourapp.com/callback" # must match app settings exactly
SCOPES = "threads_basic,threads_content_publish"
@app.route("/login")
def login():
session["state"] = secrets.token_urlsafe(24)
params = {"client_id": APP_ID, "redirect_uri": REDIRECT_URI,
"scope": SCOPES, "response_type": "code",
"state": session["state"]}
return redirect("https://threads.net/oauth/authorize?" + urlencode(params))
@app.route("/callback")
def callback():
if request.args.get("state") != session.pop("state", None):
abort(400, "State mismatch")
code = request.args["code"].removesuffix("#_")
# 1) code -> short-lived token (1 hour)
short = requests.post("https://graph.threads.net/oauth/access_token", data={
"client_id": APP_ID, "client_secret": APP_SECRET,
"grant_type": "authorization_code",
"redirect_uri": REDIRECT_URI, "code": code}).json()
# 2) short-lived -> long-lived token (60 days)
long = requests.get("https://graph.threads.net/access_token", params={
"grant_type": "th_exchange_token", "client_secret": APP_SECRET,
"access_token": short["access_token"]}).json()
save_token(user_id=short["user_id"], token=long["access_token"],
expires_at=time.time() + long["expires_in"])
return "Connected to Threads ✓"
def save_token(user_id, token, expires_at):
... # store encrypted in your database
// npm install express express-session
import express from "express";
import session from "express-session";
import crypto from "node:crypto";
const { THREADS_APP_ID: APP_ID, THREADS_APP_SECRET: APP_SECRET } = process.env;
const REDIRECT_URI = "https://yourapp.com/callback"; // must match app settings
const SCOPES = "threads_basic,threads_content_publish";
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.get("/login", (req, res) => {
req.session.state = crypto.randomBytes(24).toString("base64url");
const params = new URLSearchParams({
client_id: APP_ID, redirect_uri: REDIRECT_URI, scope: SCOPES,
response_type: "code", state: req.session.state,
});
res.redirect(`https://threads.net/oauth/authorize?${params}`);
});
app.get("/callback", async (req, res) => {
if (!req.query.state || req.query.state !== req.session.state) {
return res.status(400).send("State mismatch");
}
delete req.session.state;
const code = String(req.query.code).replace(/#_$/, "");
// 1) code -> short-lived token (1 hour)
const short = await fetch("https://graph.threads.net/oauth/access_token", {
method: "POST",
body: new URLSearchParams({
client_id: APP_ID, client_secret: APP_SECRET,
grant_type: "authorization_code", redirect_uri: REDIRECT_URI, code,
}),
}).then((r) => r.json());
// 2) short-lived -> long-lived token (60 days)
const qs = new URLSearchParams({
grant_type: "th_exchange_token", client_secret: APP_SECRET,
access_token: short.access_token,
});
const long = await fetch(`https://graph.threads.net/access_token?${qs}`).then((r) => r.json());
await saveToken(short.user_id, long.access_token, Date.now() + long.expires_in * 1000);
res.send("Connected to Threads ✓");
});
async function saveToken(userId, token, expiresAt) {
/* store encrypted in your database */
}
app.listen(3000);
# 1) Open in a browser (user approves, you get ?code=... on your redirect URI)
https://threads.net/oauth/authorize?client_id=YOUR_APP_ID&redirect_uri=https://yourapp.com/callback&scope=threads_basic,threads_content_publish&response_type=code&state=RANDOM_STRING
# 2) Exchange the code for a short-lived token (1 hour)
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": "SHORT_LIVED", "user_id": 1234567890 }
If the code in your redirect URL ends with #_, strip those characters before exchanging it. Use the exact same redirect_uri in the authorize URL, the exchange call and your app settings - a mismatch is the most common setup error.
A short-lived token expires in an hour, so swap it for a 60-day token straight away. This call must run on your server because it uses your app secret - and an expired short-lived token can't be exchanged, so do it before the hour is up.
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": "LONG_LIVED_TOKEN",
# "token_type": "bearer",
# "expires_in": 5183944 // seconds ≈ 60 days
# }curl -G https://graph.threads.net/v1.0/me \
-d fields=id,username \
-d access_token=LONG_LIVED_TOKEN
# Valid token → { "id": "1234567890", "username": "yourbrand" }
# Bad/expired → { "error": { "code": 190, "message": "...",
# "fbtrace_id": "..." } }A long-lived token can be refreshed once it's at least 24 hours old and not yet expired; the user must still have granted threads_basic. Each refresh returns a token valid for another 60 days. Miss the deadline and the user has to log in again.
curl -G https://graph.threads.net/refresh_access_token \
-d grant_type=th_refresh_token \
-d access_token=LONG_LIVED_TOKEN
# → {
# "access_token": "NEW_LONG_LIVED_TOKEN",
# "token_type": "bearer",
# "expires_in": 5183944
# }
# Requirements: token is 24h+ old, not expired,
# and the user still grants threads_basic.# Run once a day (cron, scheduler, etc.)
import time, requests
DAY = 86400
REFRESH_AFTER = 50 * DAY # refresh tokens older than ~50 days
def refresh_due_tokens(db):
now = time.time()
for row in db.tokens_issued_before(now - REFRESH_AFTER):
r = requests.get("https://graph.threads.net/refresh_access_token",
params={"grant_type": "th_refresh_token",
"access_token": row.token})
data = r.json()
if r.ok and "access_token" in data:
db.update(row.user_id,
token=data["access_token"],
issued_at=now,
expires_at=now + data["expires_in"])
else:
db.flag_reauth(row.user_id, data.get("error"))Run the job daily and refresh anything older than ~50 days, not only tokens about to expire. That leaves a margin for outages and retries. Save the new token and its new expiry each time.
Enter when a long-lived token was issued or last refreshed to see when you can refresh it, when you should, and when it expires. Times are shown in your local time zone.
A token is a key to someone's account. Treat it like a password.
Store them in your database, encrypted at rest, or in a secrets manager. Never put them in browser JavaScript, mobile app bundles, URLs or logs.
The app secret is needed for code and token exchanges. Keep it in environment variables, rotate it if it ever leaks, and never commit it to Git.
Store the Threads user ID, granted scopes, issue time and expiry with each token so your refresh job and permission checks have what they need.
Only request permissions you use. Fewer scopes mean less damage if a token leaks - and an easier App Review.
Users can remove your app from their Threads settings. Set the uninstall and data-deletion callback URLs in your app settings, and delete stored tokens when you're notified.
| What you see | Likely cause | Fix |
|---|---|---|
| Error code 190 · invalid or expired token | Token expired, was revoked, or the user changed their password or removed your app | Send the user through OAuth again; refresh tokens earlier in future |
| "Session has expired" | A short-lived token older than 1 hour, or a long-lived token past 60 days | Exchange short-lived tokens immediately; schedule refreshes |
| Refresh call rejected | Token is less than 24 hours old, already expired, or threads_basic is missing | Wait 24 hours after issue; if expired, re-authorize |
| "Invalid redirect_uri" | The URI differs between the authorize URL, exchange call and app settings | Use one identical URI everywhere, including the trailing slash |
| Authorization code rejected | Code already used, expired, or has a trailing #_ | Exchange the code once, right away, after stripping #_ |
| Permission error on a call | The user didn't grant that scope, or your app lacks advanced access | Request the scope in OAuth; get advanced access through App Review |
Always log the error code, error_subcode and fbtrace_id from failed calls - they make support requests and debugging much faster. More in our errors & rate limits guide.