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:
- 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.
- A Meta Developer App with the
instagram_basic,instagram_content_publish,pages_read_engagement, andpages_manage_postspermissions. You create this at developers.facebook.com. - 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.
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.
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
| Parameter | Where | Effect |
|---|---|---|
scheduled_publish_time | media_publish call | Unix timestamp (int) for go-live; must be 10 min to 75 days ahead. |
image_url | media container call | Public HTTPS image Meta downloads; no login or redirect allowed. |
temperature | generate_caption | Higher (0.8) varies caption wording; lower (0.3) stays on-brand. |
Troubleshooting
OAuthExceptioncode 190. Your access token expired or was revoked. Long-lived tokens last about 60 days, so regenerate one in the Graph API Explorer and updateIG_ACCESS_TOKENin.env.Invalid parametercode 100 on publish. Usuallyscheduled_publish_timeis outside the 10-minute-to-75-day window or was sent as a float. Wrap it inint()and confirm it is in the future.- Container stuck in
IN_PROGRESS. Meta cannot fetch your image. Runcheck_image_urland make sure the link is public HTTPS, returns200, has animage/jpegorimage/pngtype, and never redirects. Application request limit reached. You hit the 50-posts-per-24-hours cap. Read thex-business-use-case-usageresponse header to see your usage and slow down before retrying.Application does not have permission for this action. The token is valid but was issued withoutinstagram_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.
Back to Automated Social Media Posting.
Related guides
- Automated Social Media Posting — the section hub for every posting workflow.
- Bulk-Schedule Social Posts with Python — queue a whole calendar of posts in one run.
- Generate Twitter Threads with Python and AI — turn one idea into a structured multi-tweet thread.
- Prompt Engineering Templates for Marketers — reusable prompts to sharpen your captions.