A beginner-friendly, step-by-step tutorial: set up a Python project, connect to the Threads API, publish a text post, then add images, carousels, error handling - and finish with a reusable command-line tool you can schedule.
💡 Got code 190? Your token is invalid or expired - generate a new one and update .env.
4
Step 4 · First post
Publish your first text post
Publishing on Threads is always two calls: create a media container with your content, then publish that container.
CALL 1POST /{user-id}/threadsmedia_type=TEXT, text=…→ container id
→
CALL 2POST /{user-id}/threads_publishcreation_id=container id→ post id ✓
first_post.py
import os
import requests
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.environ["THREADS_ACCESS_TOKEN"]
BASE = "https://graph.threads.net/v1.0"
me = requests.get(f"{BASE}/me", params={"fields": "id", "access_token": TOKEN},
timeout=30).json()
# 1) Create a text container
container = requests.post(f"{BASE}/{me['id']}/threads", data={
"media_type": "TEXT",
"text": "My first post from Python 🐍",
"access_token": TOKEN,
}, timeout=30).json()
# 2) Publish it
post = requests.post(f"{BASE}/{me['id']}/threads_publish", data={
"creation_id": container["id"],
"access_token": TOKEN,
}, timeout=30).json()
print("Published:", post["id"])
$ python first_post.pyPublished: 18024689753
🎉 Open Threads - your post is live. Text posts can be up to 500 characters.
5
Step 5 · Reusable client
Turn it into a reusable Threads client
Copy-pasting requests gets messy fast. This small module wraps the API with clear errors, waits for media to finish processing, supports text, images and carousels, returns the post's link, and checks your remaining daily quota.
threads_client.py
"""threads_client.py - a tiny client for the Threads API."""
import os
import time
from functools import lru_cache
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = "https://graph.threads.net/v1.0"
TOKEN = os.environ["THREADS_ACCESS_TOKEN"]
class ThreadsError(Exception):
"""Raised when the Threads API returns an error."""
def api(method, path, **params):
"""Call the Threads API and return the JSON response."""
params["access_token"] = TOKEN
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)
data = resp.json()
if "error" in data:
err = data["error"]
raise ThreadsError(f"[{err.get('code')}] {err.get('message')}")
return data
@lru_cache(maxsize=1)
def get_me():
"""Return the connected account's id and username (cached)."""
return api("GET", "me", fields="id,username")
def wait_until_ready(container_id, timeout=300):
"""Poll a media container until Threads has finished processing it."""
deadline = time.time() + timeout
while time.time() < deadline:
info = api("GET", container_id, fields="status,error_message")
status = info.get("status")
if status == "FINISHED":
return
if status in ("ERROR", "EXPIRED"):
raise ThreadsError(f"Container {status}: {info.get('error_message')}")
time.sleep(5)
raise ThreadsError("Timed out waiting for media to process")
def create_container(user_id, **fields):
"""Create a media container and return its id."""
return api("POST", f"{user_id}/threads", **fields)["id"]
def publish(text="", image_urls=None, topic_tag=None):
"""Publish a text, image or carousel post. Returns (post_id, permalink)."""
image_urls = image_urls or []
if len(text) > 500:
raise ValueError("Text is over the 500-character limit")
if len(image_urls) > 20:
raise ValueError("A carousel can have at most 20 items")
user_id = get_me()["id"]
extra = {"topic_tag": topic_tag} if topic_tag else {}
if not image_urls:
container = create_container(user_id, media_type="TEXT", text=text, **extra)
elif len(image_urls) == 1:
container = create_container(user_id, media_type="IMAGE",
image_url=image_urls[0], text=text, **extra)
wait_until_ready(container)
else:
children = []
for url in image_urls:
child = create_container(user_id, media_type="IMAGE",
image_url=url, is_carousel_item="true")
wait_until_ready(child)
children.append(child)
container = create_container(user_id, media_type="CAROUSEL",
children=",".join(children), text=text, **extra)
wait_until_ready(container)
post_id = api("POST", f"{user_id}/threads_publish", creation_id=container)["id"]
permalink = api("GET", post_id, fields="permalink").get("permalink")
return post_id, permalink
def remaining_posts():
"""How many more posts this account can publish in the current 24 hours."""
user_id = get_me()["id"]
data = api("GET", f"{user_id}/threads_publishing_limit",
fields="quota_usage,config")["data"][0]
return data["config"]["quota_total"] - data["quota_usage"]
api()One place for every request, raising ThreadsError on failures
get_me()Your account ID and username, cached after the first call
wait_until_ready()Polls media containers until they're FINISHED
publish()Text, image or carousel - returns the post ID and link
remaining_posts()How many posts you can still publish today
6
Step 6 · Images & carousels
Post images and carousels
Pass one image URL for an image post, or 2–20 URLs for a carousel. Images must be publicly reachable (Threads downloads them), JPEG or PNG, up to 8 MB. The client waits for each to finish processing before publishing.
media.py
from threads_client import publish
# A single image (the URL must be publicly reachable)
post_id, link = publish("Fresh out of the oven 🍞",
image_urls=["https://example.com/bread.jpg"])
# A carousel of 2-20 images, with a topic tag
post_id, link = publish("Before → after. Swipe ➡️",
image_urls=["https://example.com/before.jpg",
"https://example.com/after.jpg"],
topic_tag="Baking")
print(link)
image 1
image 2
image 3
→
CAROUSEL container
→
Published ✓
7
Step 7 · Errors & limits
Handle errors and rate limits
Each account can publish up to 250 posts in 24 hours through the API. Check your quota before posting and catch ThreadsError so a failure doesn't crash a scheduled job.
safe.py
from threads_client import ThreadsError, publish, remaining_posts
if remaining_posts() > 0:
try:
post_id, link = publish("Scheduled update ⏰")
print("Live at", link)
except ThreadsError as err:
print("Threads said no:", err)
else:
print("Daily limit reached - try again later.")
You see
Why
Fix
[190] Invalid OAuth access token
Token expired or wrong
Get a new long-lived token; refresh before 60 days
Container ERROR
Image URL not public, wrong format or too big
Use a public JPEG/PNG URL under 8 MB
Permission error
threads_content_publish not granted
Add the scope and re-authorize
Daily limit reached
250 posts in the last 24 hours
Wait - the quota rolls over continuously
8
Step 8 · Command-line tool
Build a command-line posting tool
Wrap it all in a script you can run from the terminal - or from a scheduler.
post.py
"""post.py - publish to Threads from the command line.
Usage:
python post.py "Hello, Threads!"
python post.py "New drop" --image https://example.com/a.jpg
python post.py "Swipe ->" --image URL1 --image URL2 --topic Python
"""
import argparse
import sys
from threads_client import ThreadsError, get_me, publish, remaining_posts
def main():
parser = argparse.ArgumentParser(description="Publish a post to Threads.")
parser.add_argument("text", help="Post text (up to 500 characters)")
parser.add_argument("--image", action="append", default=[],
help="Public image URL (repeat for a carousel)")
parser.add_argument("--topic", help="Optional topic tag")
args = parser.parse_args()
try:
me = get_me()
print(f"→ Connected as @{me['username']}")
if remaining_posts() < 1:
sys.exit("✕ Daily publishing limit reached - try again later.")
post_id, link = publish(args.text, args.image, args.topic)
print(f"✓ Published {post_id}")
print(f" {link}")
except (ThreadsError, ValueError) as err:
sys.exit(f"✕ {err}")
if __name__ == "__main__":
main()
$ python post.py "Hello from the command line 👋" --topic Python→ Connected as @yourbrand ✓ Published 18024689753 https://www.threads.com/@yourbrand/post/…
Try it
Threads post builder for Python
Write your post, add images or a topic tag, and get the exact publish() call for the client above. Everything runs in your browser - nothing is posted or saved.
yourbrandpreview
Text post
Next steps
Where to go from here
⏰
Schedule posts with cron
Run post.py on a schedule. On Mac or Linux, crontab -e and add a line like the one below. On Windows, use Task Scheduler.
Long-lived tokens expire after 60 days. Add a daily refresh job so your scheduled posts never stop - see the access tokens guide.
📊
Read your post insights
Pull views, likes, replies and reposts for each post with the Insights API, and log them to a spreadsheet - see the API guide.
FAQ
Threads API Python FAQ
Is there an official Threads Python SDK?
No - Meta doesn't publish an official Python SDK for Threads. The API is plain REST, so the requests library is all you need. The small threads_client.py module in this tutorial covers posting, media, errors and quota checks.
How do I post to Threads with Python?
Make two API calls: POST to /{user-id}/threads with media_type=TEXT and your text to create a container, then POST to /{user-id}/threads_publish with creation_id set to that container's ID. Both calls include your access token.
Can I post images to Threads with Python?
Yes. Create a container with media_type=IMAGE and a public image_url, wait until its status is FINISHED, then publish it. For a carousel, create 2–20 image containers with is_carousel_item=true, then a CAROUSEL container listing their IDs as children.
Can I upload an image file from my computer?
Not directly. The Threads API takes an image URL, not a file upload. Host the image somewhere public first - for example cloud storage such as Amazon S3 or Cloudflare R2 - and pass its URL.
How many posts can I publish with the API?
Up to 250 posts per account in a rolling 24-hour window. Check remaining quota with the threads_publishing_limit endpoint, as remaining_posts() does in this tutorial.
Can I schedule Threads posts with Python?
Yes. Run the post.py script from cron (Mac/Linux) or Task Scheduler (Windows) at the times you want. Keep your long-lived token refreshed so scheduled posts keep working past 60 days.