What every Threads API error means and how to fix it - error codes, media container failures, per-account quotas and app-level rate limits - plus an error decoder and tested retry code that backs off safely without ever posting twice.
POST /threadscontainer createdPOST /threads_publishthrottledbackoff + jitterretry 1POST /threads_publishstill throttledbackoff × 2retry 2POST /threads_publishpublished ✓The Threads API follows Graph API conventions: when a call fails, the response contains an error object. Five fields tell you almost everything you need.
{
"error": {
1"message": "Error validating access token: Session has expired",
2"type": "OAuthException",
3"code": 190,
4"error_subcode": 463,
5"fbtrace_id": "AbC123xYz789"
}
}A human-readable description. Useful for logs - but don't branch your code on it, as wording can change.
The error family, such as OAuthException for token and permission problems.
The number to act on - it tells you whether to fix, re-authorize or retry. See the code reference.
Extra detail when present - for example, why a token is invalid.
A unique ID for this request. Always log it - Meta support needs it to trace what happened.
Paste an error response (or a container status response) to see what it means, whether to retry, and how to fix it. It runs in your browser - nothing is sent or stored. Remove any access tokens before pasting.
The codes developers most often see, what causes them, and what to do. Filter by category or search.
| Code | Meaning | Category | Retry? | How to fix |
|---|---|---|---|---|
1 | Unknown errorA temporary or unexpected problem on Meta's side. | Server | Retry with backoff | Retry read calls with backoff. If it keeps happening, check Meta's platform status and log the fbtrace_id. |
2 | Service temporarily unavailableTemporary downtime or overload. | Server | Retry with backoff | Wait and retry read calls with backoff. For publish calls, check the container status before trying again. |
4 | Application request limit reachedYour app has made too many calls in the current window. | Rate limit | Retry with backoff | Back off exponentially, spread calls out, cache responses and replace polling with webhooks. |
10 | Permission deniedThe app or user isn't allowed to do this. | Fix first | Request the right permission and, for production users, get advanced access through App Review. | |
17 | User request limit reachedToo many calls for one user account. | Rate limit | Retry with backoff | Slow down calls for that account and retry with backoff. |
100 | Invalid parameterA parameter is missing, misspelled or has an invalid value - for example text over 500 characters, an unsupported media_type, or a topic tag with a period. | Request | Fix first | Check parameter names and values against the docs, then send a corrected request. |
190 | Invalid or expired access tokenThe token expired, was revoked, or is malformed. | Auth | Fix first | Send the user through OAuth again. Refresh long-lived tokens well before their 60 days are up. |
200–299 | Permission errorA permission (scope) the endpoint needs wasn't granted. | Fix first | Add the scope to your authorization request and have the user re-authorize. | |
341 | Application limit reachedA temporary limit has been applied to your app. | Rate limit | Retry with backoff | Pause, then retry later with backoff. Reduce call volume. |
368 | Temporarily blockedThe action was blocked, often because it looks spammy or raises policy concerns. | Policy | Fix first | Stop the action, review your content and posting volume, and try again later. |
613 | Rate limit exceededCalls exceeded a rate limit. | Rate limit | Retry with backoff | Back off exponentially and lower your request frequency. |
No codes match - try another search.
These are standard Graph API codes that the Threads API also uses. Exact messages and subcodes can vary, so always log the full error object and fbtrace_id.
Images and videos are processed before they can be published. Check a container's status - Meta recommends about once a minute, for no more than 5 minutes. A container that isn't published within 24 hours expires.
| error_message | What went wrong | Fix |
|---|---|---|
FAILED_DOWNLOADING_VIDEO | Threads couldn't download the video from your URL. | Use a public, direct file URL - no login pages, redirects or expiring links - on a fast host. |
FAILED_PROCESSING_AUDIO | The audio track couldn't be processed. | Re-encode the audio as AAC and upload again. |
FAILED_PROCESSING_VIDEO | The video couldn't be processed. | Re-encode as MP4 (H.264 video, AAC audio) and try again. |
INVALID_ASPECT_RATIO | The video's shape is outside the allowed range. | Keep between 0.01:1 and 10:1 - 9:16 vertical is recommended. |
INVALID_BIT_RATE | The video's bitrate is outside the allowed range. | Re-export with a standard bitrate preset. |
INVALID_DURATION | The video is too long. | Keep videos to 5 minutes or less. |
INVALID_FRAME_RATE | The frame rate is outside the allowed range. | Use 23–60 frames per second. |
INVALID_AUDIO_CHANNELS | Unsupported number of audio channels. | Use mono or stereo audio. |
INVALID_AUDIO_CHANNEL_LAYOUT | Unsupported audio channel layout. | Re-export the audio as standard mono or stereo. |
UNKNOWN | An unspecified processing error. | Retry once with a fresh container. If it fails again, re-encode the file and check the URL. |
There are two kinds of limit: per-account quotas on actions like posting and replying, and an app-level call limit on how many requests your app makes overall.
Bars show example usage. Quotas roll over continuously - there's no daily reset time.
curl -G "https://graph.threads.net/v1.0/$THREADS_USER_ID/threads_publishing_limit" \
-d fields=quota_usage,config,reply_quota_usage,reply_config,delete_quota_usage,location_search_quota_usage \
-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 },
# ...
# } ] }Your app can make 4,800 × impressions calls in a rolling 24 hours, where impressions is how many times content from the connected Threads account was seen on screen in that period. If impressions are under 10, Meta counts 10 - so every app gets at least 48,000 calls a day. There are matching limits on total CPU time and request time.
💡 Heavy polling is the usual way apps hit this. Cache responses, batch work, and use webhooks instead of asking "anything new?" every few seconds.
Retry only errors that can succeed on a second try, wait longer each time, and never resend a publish that might already have gone through.
IN_PROGRESS - check again in about a minuteERROR - fix the media file or URLWait time doubles each retry Random jitter (up to 1s) so many clients don't retry at the same moment
If a threads_publish call times out, the post may already be live. Retrying could post it twice. Instead, check the container's status: if it's PUBLISHED, you're done. The helpers below only retry POSTs when they were clearly throttled, and publish_container() checks first.
"""threads_retry.py - call the Threads API with safe retries and backoff."""
import random
import time
import requests
BASE_URL = "https://graph.threads.net/v1.0"
RATE_LIMIT_CODES = {4, 17, 341, 613} # throttled: the request was rejected
TRANSIENT_CODES = {1, 2} # temporary problems on Meta's side
class ThreadsError(Exception):
"""A Threads API error with its code, subcode and trace id."""
def __init__(self, error, status=None):
self.code = error.get("code")
self.subcode = error.get("error_subcode")
self.trace_id = error.get("fbtrace_id")
self.status = status
super().__init__(f"[{self.code}/{self.subcode}] {error.get('message')} "
f"(fbtrace_id={self.trace_id})")
def _backoff(attempt):
"""Exponential backoff with jitter: ~1s, 2s, 4s, 8s, 16s... capped at 60s."""
time.sleep(min(60, 2 ** attempt) + random.uniform(0, 1))
def call(method, path, token, max_retries=5, **params):
"""Call the API. GETs retry on temporary errors; POSTs only retry when
throttled, so a request that may have succeeded is never sent twice."""
params["access_token"] = token
for attempt in range(max_retries + 1):
last = attempt == max_retries
try:
if method == "GET":
resp = requests.get(f"{BASE_URL}/{path}", params=params, timeout=30)
else:
resp = requests.post(f"{BASE_URL}/{path}", data=params, timeout=30)
except (requests.ConnectionError, requests.Timeout):
if method != "GET" or last:
raise
_backoff(attempt)
continue
try:
data = resp.json()
except ValueError:
data = {"error": {"message": f"HTTP {resp.status_code}"}}
error = data.get("error")
if not error:
return data
code = error.get("code")
throttled = code in RATE_LIMIT_CODES
transient = code in TRANSIENT_CODES or resp.status_code >= 500
if last or not (throttled or (transient and method == "GET")):
raise ThreadsError(error, resp.status_code)
_backoff(attempt)
def publish_container(user_id, container_id, token):
"""Publish a container - skipping it if an earlier attempt already did."""
info = call("GET", container_id, token, fields="status,error_message")
if info.get("status") == "PUBLISHED":
return None # already live: don't post twice
if info.get("status") != "FINISHED":
raise RuntimeError(f"Container not ready: {info}")
return call("POST", f"{user_id}/threads_publish", token,
creation_id=container_id)["id"]
// threadsRetry.mjs - Node.js 18+
const BASE_URL = "https://graph.threads.net/v1.0";
const RATE_LIMIT_CODES = new Set([4, 17, 341, 613]); // throttled: request rejected
const TRANSIENT_CODES = new Set([1, 2]); // temporary, Meta's side
export class ThreadsError extends Error {
constructor(error, status) {
super(`[${error.code}/${error.error_subcode}] ${error.message} (fbtrace_id=${error.fbtrace_id})`);
this.code = error.code;
this.subcode = error.error_subcode;
this.traceId = error.fbtrace_id;
this.status = status;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Exponential backoff with jitter: ~1s, 2s, 4s, 8s, 16s... capped at 60s
const backoff = (attempt) => sleep(Math.min(60, 2 ** attempt) * 1000 + Math.random() * 1000);
// GETs retry on temporary errors; POSTs only retry when throttled,
// so a request that may have succeeded is never sent twice.
export async function call(method, path, token, params = {}, maxRetries = 5) {
const body = new URLSearchParams({ ...params, access_token: token });
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const last = attempt === maxRetries;
let res;
try {
res = method === "GET"
? await fetch(`${BASE_URL}/${path}?${body}`, { signal: AbortSignal.timeout(30000) })
: await fetch(`${BASE_URL}/${path}`, { method, body, signal: AbortSignal.timeout(30000) });
} catch (err) {
if (method !== "GET" || last) throw err;
await backoff(attempt);
continue;
}
const data = await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }));
if (!data.error) return data;
const throttled = RATE_LIMIT_CODES.has(data.error.code);
const transient = TRANSIENT_CODES.has(data.error.code) || res.status >= 500;
if (last || !(throttled || (transient && method === "GET"))) {
throw new ThreadsError(data.error, res.status);
}
await backoff(attempt);
}
}
Work through these in order - most problems are found in the first three.
code, error_subcode, message, fbtrace_id and the endpoint you called - never just "request failed".GET /me?fields=id,username. A 190 here means the token, not your request, is the problem.threads_publishing_limit to see if you've hit the posting, reply or delete limit.status,error_message on the container and fix any ERROR reason.fbtrace_id and a timestamp.