Python tutorial ·

Publish Your First Threads Post with Python

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.

~15 minfrom empty folder to your first live post
3.9+Python version - works on Mac, Windows and Linux
2 librariesrequests and python-dotenv
2 callscreate a container, then publish it
1
Step 1 · Prerequisites

What you need before you start

  • Python 3.9 or newer. Check with python3 --version.
  • A Meta app with the Threads use case and the threads_basic and threads_content_publish permissions.
  • Your Threads account added as a tester on that app, with the invite accepted in the Threads app.
  • A long-lived access token (60 days) for that account. New to tokens? Follow our access tokens guide first.

💡 Testing on your own tester account needs no App Review - you can publish real posts today.

2
Step 2 · Setup

Set up the Python project

Create a folder and a virtual environment, install the two libraries, and keep your token in a .env file so it never ends up in your code.

Terminal
mkdir threads-bot && cd threads-bot
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install requests python-dotenv
.env
THREADS_ACCESS_TOKEN=paste-your-long-lived-token-here
.gitignore
.env
.venv/
__pycache__/
threads-bot/ ├── .env your token (never commit) ├── .gitignore ├── hello.py step 3 ├── first_post.py step 4 ├── threads_client.py step 5 └── post.py step 8
3
Step 3 · Connect

Test your connection to the Threads API

Before posting anything, ask the API who you are. If you see your ID and username, your token works.

hello.py
import os

import requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["THREADS_ACCESS_TOKEN"]

r = requests.get("https://graph.threads.net/v1.0/me",
                 params={"fields": "id,username", "access_token": TOKEN},
                 timeout=30)
print(r.json())
# {'id': '1234567890', 'username': 'yourbrand'}
$ python hello.py{'id': '1234567890', 'username': 'yourbrand'}

💡 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.

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)
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 seeWhyFix
[190] Invalid OAuth access tokenToken expired or wrongGet a new long-lived token; refresh before 60 days
Container ERRORImage URL not public, wrong format or too bigUse a public JPEG/PNG URL under 8 MB
Permission errorthreads_content_publish not grantedAdd the scope and re-authorize
Daily limit reached250 posts in the last 24 hoursWait - 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()
Run it
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
$ 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.

0 9 * * 1-5 cd ~/threads-bot && .venv/bin/python post.py "Good morning ☀️"
🔄

Keep your token alive

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.