Content & Marketing

Schedule Instagram Posts Using Python and AI

Automate Instagram publishing with Python: generate a caption with an LLM, then schedule and publish through the Instagram Graph API using httpx.

This guide shows you how to write an Instagram caption with AI and then schedule the post through Instagram's official API in about 20 minutes, with no third-party posting service in the middle. You stay in control of the caption, the timing, and the image, and everything runs from a single Python script.

The approach has two halves. First, an LLM (large language model, the kind of AI that writes text) drafts a caption and hashtags. Second, the Instagram Graph API (Meta's official programming interface for Instagram Business accounts) creates the post and sets it to publish at a future time. This fits naturally into a wider Automated Social Media Posting routine and the broader AI Content Creation & Marketing Automation workflow.

Prerequisites

This guide assumes you already have Python 3.10 or newer and a working virtual environment. If you do not, follow Create a Python Virtual Environment for AI first. Beyond that, you need three Instagram-specific things:

  1. An Instagram Business or Creator account linked to a Facebook Page. Personal accounts cannot publish through the API. You can convert your account for free in the Instagram app under Settings.
  2. A Meta Developer App with the instagram_basic, instagram_content_publish, pages_read_engagement, and pages_manage_posts permissions. You create this at developers.facebook.com.
  3. A long-lived access token generated through the Meta Graph API Explorer. Short-lived tokens expire in about an hour; the long-lived version lasts roughly 60 days.

Those three pieces stack on top of each other. Each layer only works when the one beneath it is already in place, which is why a missing Page link usually surfaces much later as a baffling permissions error rather than an obvious one.

The four layers a scheduled Instagram post depends on A stack of four layers, from an Instagram Business account linked to a Facebook Page at the bottom, through Meta app scopes and your Python script, up to the post going live on Meta's servers. Scheduled post goes live on Meta what you get Python script httpx + openai SDK your code Meta app scopes content publish scope Meta app setup Business account linked to a Page account setting
Set these up from the bottom up: the account type, then the app scopes, then the script. A gap in a lower layer always shows up as an error in a higher one.

Install the libraries used here:

pip install openai httpx python-dotenv

We use httpx (a modern HTTP client that handles both regular and async requests) for all calls to Meta, and the openai SDK for the caption. Store every secret in a file named .env in your project folder:

IG_USER_ID=your_ig_business_account_id
IG_ACCESS_TOKEN=your_long_lived_token
OPENAI_API_KEY=sk-...

Then immediately add that file to your ignore list so it never reaches a public repository:

echo ".env" >> .gitignore

A leaked access token lets anyone post as you, so this one line matters more than it looks. The moment this script stops running on your own laptop, a .env file is no longer the right home for the token — Manage API Keys Safely in Production covers the storage options worth moving to.

Step 1: Generate the caption with an LLM

Ask the model for structured output so you can read the caption and hashtags separately instead of parsing one blob of text. We request JSON and set response_format so the model is forced to return valid JSON. The same prompt-as-template idea appears in Prompt Engineering Templates for Marketers if you want to refine the wording.

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def generate_caption(topic: str) -> dict:
    """Return {'caption': str, 'hashtags': [str, ...]} for a topic."""
    prompt = (
        f"Write an Instagram caption for: '{topic}'. "
        "Keep the caption under 2000 characters, friendly and concrete. "
        "Return JSON with two keys: 'caption' (string) and "
        "'hashtags' (a list of 5 to 8 short hashtag strings without the # sign)."
    )

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
        temperature=0.8,
    )
    return json.loads(response.choices[0].message.content)

The temperature of 0.8 keeps captions varied across runs so your feed does not read like a template. Lower it toward 0.3 if you want a steadier, more predictable brand voice. Should json.loads ever raise on the reply, Fix JSONDecodeError with AI API Responses in Python walks through the usual causes and a safe retry.

A small helper turns the model's output into one caption string. Instagram counts hashtags toward the 2,200-character limit and allows at most 30 of them, so we keep the list short and join everything cleanly:

def build_full_caption(content: dict) -> str:
    tags = " ".join(f"#{t.lstrip('#')}" for t in content["hashtags"])
    caption = content["caption"].strip()
    return f"{caption}\n\n{tags}"[:2200]

Step 2: Prepare the media

Meta does not accept image uploads from your machine for feed posts. Instead, you give it a public URL and Meta's servers download the file. That means the image must live somewhere reachable by anyone, over HTTPS, with no login and no redirect. Common hosts are an S3 bucket, a Cloudflare R2 bucket, or any plain static web server.

Before you try to publish, confirm the URL behaves the way Meta expects:

import httpx


def check_image_url(img_url: str) -> None:
    """Raise if the URL is not a directly reachable image."""
    resp = httpx.head(img_url, follow_redirects=False, timeout=15)
    if resp.status_code != 200:
        raise ValueError(f"Image URL returned {resp.status_code}, expected 200")

    content_type = resp.headers.get("content-type", "")
    if not content_type.startswith(("image/jpeg", "image/png")):
        raise ValueError(f"Unexpected content-type: {content_type!r}")

Running this check first turns a confusing Graph API error later into a clear message now. Instagram feed images also work best as JPEG between 320 and 1440 pixels wide, with an aspect ratio from 4:5 to 1.91:1.

If the artwork needs a headline, a price, or a launch date burned into the pixels, do that before you upload: Add Text Overlays to AI Images with Python draws the text on the file, and only the finished image goes to your host.

Step 3: Create and schedule the post

Publishing is a two-call dance. First you create a media container (Meta's staging slot that holds the image plus caption). The container is not instant: Meta downloads and processes the image, so you poll a status field until it reads FINISHED. Then you publish the container, and that is where the scheduled time goes.

Set scheduled_publish_time to a Unix timestamp (a plain integer count of seconds) between 10 minutes and 75 days in the future. It must be an int — floating-point values are rejected.

Instagram scheduled-post flow Caption and image feed into a media container, which is polled until finished, then published with a future timestamp. AI caption + image URL Create media container Poll until FINISHED Publish with future time
The caption and image become a container; once Meta marks it FINISHED, you publish it with a future timestamp.
import time

IG_USER_ID = os.getenv("IG_USER_ID")
TOKEN = os.getenv("IG_ACCESS_TOKEN")
BASE_URL = f"https://graph.facebook.com/v18.0/{IG_USER_ID}"


def create_container(client: httpx.Client, img_url: str, caption: str) -> str:
    resp = client.post(
        f"{BASE_URL}/media",
        params={"image_url": img_url, "caption": caption, "access_token": TOKEN},
    )
    resp.raise_for_status()
    return resp.json()["id"]


def wait_until_ready(client: httpx.Client, container_id: str) -> None:
    for _ in range(10):
        resp = client.get(
            f"https://graph.facebook.com/v18.0/{container_id}",
            params={"fields": "status_code", "access_token": TOKEN},
        )
        if resp.json().get("status_code") == "FINISHED":
            return
        time.sleep(3)
    raise RuntimeError("Media container did not reach FINISHED in time")


def schedule_post(img_url: str, caption: str, hours_from_now: int = 24) -> dict:
    with httpx.Client(timeout=30) as client:
        container_id = create_container(client, img_url, caption)
        wait_until_ready(client, container_id)

        publish_at = int(time.time()) + hours_from_now * 3600
        resp = client.post(
            f"{BASE_URL}/media_publish",
            params={
                "creation_id": container_id,
                "scheduled_publish_time": publish_at,
                "access_token": TOKEN,
            },
        )
        resp.raise_for_status()
        return resp.json()

If you want a post to go out immediately instead, simply omit scheduled_publish_time from the second call.

Step 4: Verify the scheduled post

A successful publish call returns JSON with an id. Print it and confirm the post shows up under your Instagram scheduled content (in the Meta Business Suite planner). Wiring the verify step into the run makes failures loud instead of silent:

def main() -> None:
    content = generate_caption("Our new Python automation course launch")
    caption = build_full_caption(content)
    check_image_url("https://your-cdn.example.com/launch.jpg")

    result = schedule_post(
        "https://your-cdn.example.com/launch.jpg",
        caption,
        hours_from_now=24,
    )
    print(f"Scheduled. Post creation id: {result['id']}")


if __name__ == "__main__":
    main()

That is the full loop: caption, media check, schedule, confirm. Because Meta holds the scheduled post on its own servers, your computer can be off when the post actually goes live. Posts that publish on their own also collect comments on their own, so Auto-Reply to Comments with Python and AI is the natural companion once this script is running.

Step 5: Run the script on a timer

Meta handles the delay between scheduling and publishing, but something still has to run main() in the first place. Doing that by hand every Monday defeats the point. A hosted job runner solves it for free at this scale: it checks out your repository on a schedule, installs the dependencies, and runs the file.

Save this as .github/workflows/instagram.yml next to your script:

name: Schedule next Instagram post
on:
  schedule:
    - cron: "0 9 * * 1"          # 09:00 UTC every Monday
  workflow_dispatch:              # lets you trigger a run by hand
jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install openai httpx python-dotenv
      - run: python schedule_post.py
        env:
          IG_USER_ID: ${{ secrets.IG_USER_ID }}
          IG_ACCESS_TOKEN: ${{ secrets.IG_ACCESS_TOKEN }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Nothing in the Python file changes: load_dotenv() quietly does nothing when there is no .env, and os.getenv reads the values the runner injected instead. Add the three secrets under the repository's Settings, then Secrets and variables, then Actions. Schedule Python AI Jobs with GitHub Actions goes further into cron syntax, time zones, and reading the run log when a job fails silently.

Key parameters quick reference

ParameterWhereEffect
scheduled_publish_timemedia_publish callUnix timestamp (int) for go-live; must be 10 min to 75 days ahead.
image_urlmedia container callPublic HTTPS image Meta downloads; no login or redirect allowed.
temperaturegenerate_captionHigher (0.8) varies caption wording; lower (0.3) stays on-brand.

Troubleshooting

  1. OAuthException code 190. Your access token expired or was revoked. Long-lived tokens last about 60 days, so regenerate one in the Graph API Explorer and update IG_ACCESS_TOKEN in .env.
  2. Invalid parameter code 100 on publish. Usually scheduled_publish_time is outside the 10-minute-to-75-day window or was sent as a float. Wrap it in int() and confirm it is in the future.
  3. Container stuck in IN_PROGRESS. Meta cannot fetch your image. Run check_image_url and make sure the link is public HTTPS, returns 200, has an image/jpeg or image/png type, and never redirects.
  4. Application request limit reached. You hit the 50-posts-per-24-hours cap. Read the x-business-use-case-usage response header to see your usage and slow down before retrying.
  5. Application does not have permission for this action. The token is valid but was issued without instagram_content_publish. Re-request the permission in the Graph API Explorer, generate a fresh long-lived token, and replace the old one.

When to use this vs. alternatives

  • Use this script when you want one Instagram account on autopilot with AI captions and full control over timing, and you are comfortable managing a Meta access token.
  • Use Bulk-Schedule Social Posts with Python when you are queuing many posts at once across a content calendar, where reading rows from a spreadsheet matters more than per-post tuning.
  • Use a hosted scheduler (Buffer, Later, Meta Business Suite by hand) when you do not want to maintain code or tokens at all and a monthly fee is acceptable. You trade flexibility and AI integration for convenience.

Two questions settle it in practice: whether you want the publishing step to live in your own code at all, and whether each post is hand-tuned or one row in a queue. Follow them in that order.

Choosing between this script, a bulk run, and a hosted scheduler A decision tree: if you do not publish from your own code, use a hosted scheduler; if you do, one hand-tuned post at a time points to this guide, while a queue of posts points to the bulk-scheduling guide. Publishing from your own code? yes no One post at a time, hand-tuned? Hosted scheduler no code, monthly fee Use this guide AI caption per post Bulk-schedule run rows from a sheet
The Graph API route only pays off when the publishing step lives in code; from there the choice is per-post control against queueing a whole calendar in one run.

Back to Automated Social Media Posting.

Frequently asked questions

Can you schedule Instagram posts with the Graph API?

Yes. The Instagram Graph API lets a Business or Creator account create a media container and publish it, and Meta supports a scheduled_publish_time between 10 minutes and 75 days in the future for single image and video posts.

Do I need a personal Instagram account or a Business account?

You need an Instagram Business or Creator account that is linked to a Facebook Page. Personal accounts cannot use the Content Publishing API, so convert your account in the Instagram app settings first.

Why does my image fail to publish even though the URL works in my browser?

Meta's servers fetch the image themselves, so it must be a public HTTPS URL with no login, no redirect, and a valid image/jpeg or image/png type. Links behind authentication or signed URLs that expire often fail.

How many posts can I publish per day through the API?

Instagram allows 50 API-published posts per account in a rolling 24-hour window. The x-business-use-case-usage response header reports how much of that quota you have used.

Do I have to keep my computer running for scheduled posts?

If you set scheduled_publish_time, Meta publishes the post on its own servers, so your machine can be off. If you instead schedule with a local tool like APScheduler, your script must be running at publish time.