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